Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@
},
"metadata": {
"description": "CBEPX fork of the OpenAI Codex plugin for Claude Code: max/ultra effort, per-thread config overrides, gpt-5.6 aliases, rescue agent fixes.",
"version": "1.1.0"
"version": "1.1.1"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.1.0",
"version": "1.1.1",
"author": {
"name": "OpenAI"
},
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/pull-request-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ jobs:
- name: Run test suite
run: npm test

- name: No leaked test processes
run: |
sleep 10
if pgrep -f codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi

- name: Run build
run: npm run build
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 1.1.1 — 2026-08-28

- Broker idle self-terminate (upstream #457): the shared Codex runtime exits after 30 minutes without a connected client (`CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS` / `--idle-timeout`), so idle brokers and their app-server children no longer accumulate (#543).
- Broker lifecycle races found in #457: a broker that self-terminates on idle now drops its `broker.json` ownership record (a later `SessionEnd` could otherwise signal a recycled PID, and `status` could advertise a dead endpoint), teardown verifies the recorded PID really is this session's broker before signalling it, and the broker stops listening before it closes its app-server child so a client connecting mid-shutdown is refused instead of being served and then failing its first RPC.
- Test suite no longer leaks fake `codex app-server`/broker processes (5 s idle timeout in the test environment; CI fails if any `codex-plugin-test-*` process survives).
- Rescue shell blocks use `command rm -f --` in their cleanup trap (no noise from `rm` aliases such as `trash`).

## 1.1.0 — 2026-08-27

Fork of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) 1.0.6 (`db52e28`). Marketplace `cbepx`, plugin name unchanged (`codex`).
Expand Down
100 changes: 100 additions & 0 deletions docs/superpowers/plans/2026-08-28-codex-plugin-cc-v1.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# codex-plugin-cc v1.1.1 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Patch release that stops the process leaks (idle brokers in real use, fake app-servers/brokers in the test suite) and fixes the `rm`-alias noise in the rescue shell blocks.

**Architecture:** Merge upstream PR #457 (broker idle self-terminate; env/flag override) and reuse that mechanism in tests via a 2 s idle timeout in `buildEnv()`, gated in CI by a post-test `pgrep` check. `command rm -f --` in the four `trap` lines. Then bump 1.1.1, CHANGELOG, release.

**Tech Stack:** Node ≥18.18, ESM `.mjs`, `node --test`, fake Codex fixture, `gh`.

**Spec:** memory `codex-plugin-cc-fork-backlog` (v1.1.1 items) — leak evidence: 3147 fake `codex app-server` processes after one day of `npm test` runs; 12 real idle brokers + app-servers days old; `trash: : path does not exist` from `trap 'rm -f …'` with the user's `rm→trash` alias.

## Global Constraints

- Repo `/Users/g.mehrenin/project/personal/codex-plugin-cc`, `origin`=CBEPX, `upstream`=openai. Branch `release/v1.1.1` from `main` (23942d7 = v1.1.0).
- Test gate: `npm test > /tmp/npm-test.log 2>&1; st=$?; rg -e 'ℹ (tests|pass|fail)' -e '^not ok' /tmp/npm-test.log; test "$st" -eq 0` (142 tests at base). `npm run build`, `npm run check-version`, `claude plugin validate . --strict` before the release commit.
- Tooling rule (user): never `grep` — ripgrep `rg` only. No `git add -A` (`.superpowers/` untracked). Commit trailer `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`. No push until the controller says so.
- Merge mechanics for #457: `git fetch upstream pull/457/head:pr/457 && git merge --no-ff --no-edit pr/457`; keep-both on conflicts (the fork's `withAppServer` third param and `runAppServerTurn` `disableBroker` must survive).

---

### Task 1: Merge #457 (broker idle self-terminate) + test idle timeout + CI gate + `command rm`

**Files:**
- Merge: `plugins/codex/scripts/app-server-broker.mjs`, `tests/broker-idle-timeout.test.mjs` (from PR #457)
- Modify: `tests/fake-codex-fixture.mjs` (`buildEnv`), `tests/runtime.test.mjs` (one assertion in the existing broker-reuse test), `.github/workflows/pull-request-ci.yml`, `plugins/codex/commands/rescue.md` (2 `trap` lines), `plugins/codex/agents/codex-rescue.md` (2 `trap` lines), `tests/commands.test.mjs`

**Interfaces:**
- Consumes (from #457): env `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS`, broker flag `--idle-timeout <ms>`, default 30 min; broker exits and shuts down its app-server child when no client is connected for that long.
- Produces: `buildEnv(binDir, …)` sets `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "2000"` unless the caller passes its own; CI step `sleep 5; ! pgrep -f codex-plugin-test-` after `npm test`; `trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT` in all four shell blocks.

- [ ] **Step 1: Branch + merge #457**

```bash
git checkout -b release/v1.1.1 main
git fetch upstream pull/457/head:pr/457 && git merge --no-ff --no-edit pr/457
```
Expected conflicts: none or trivial (broker file untouched by the fork). Gate → `fail 0`; `tests/broker-idle-timeout.test.mjs` present and passing.

- [ ] **Step 2: Failing test — test brokers self-terminate**

In `tests/runtime.test.mjs`, find the existing broker-reuse test (the one asserting `appServerStarts` stays at 1 across two `task` runs, or `loadBrokerSession`); after its last companion call append:

```js
const session = JSON.parse(fs.readFileSync(path.join(repo, ".codex-companion", "broker.json"), "utf8")); // adapt: use loadBrokerSession(repo) / the real broker.json path the fixture exposes
const brokerPid = session.pid;
assert.ok(brokerPid > 0);
const deadline = Date.now() + 6000;
let alive = true;
while (alive && Date.now() < deadline) {
try { process.kill(brokerPid, 0); await new Promise((r) => setTimeout(r, 200)); } catch { alive = false; }
}
assert.equal(alive, false, `broker ${brokerPid} should exit within the 2 s test idle timeout`);
```
(Make the test `async`. If the broker pid lives in `~/.claude/plugins/data` state rather than the repo, read it from `loadBrokerSession(...)` exported by `plugins/codex/scripts/lib/broker-lifecycle.mjs` — check its signature first.)

- [ ] **Step 3: Run — expect FAIL** (broker still alive: default idle timeout is 30 min).

- [ ] **Step 4: `buildEnv` sets the 2 s idle timeout** — in `tests/fake-codex-fixture.mjs` `buildEnv(binDir, overrides = {})` add `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "2000"` before spreading caller overrides (so `broker-idle-timeout.test.mjs` can still set its own value).

- [ ] **Step 5: Run — expect PASS.** Then full gate → `fail 0`. Then `sleep 5; pgrep -fl codex-plugin-test- | wc -l` → 0.

- [ ] **Step 6: CI gate** — in `.github/workflows/pull-request-ci.yml` after the `Run test suite` step add:
```yaml
- name: No leaked test processes
run: |
sleep 5
if pgrep -f codex-plugin-test- ; then echo "leaked test processes" >&2; exit 1; fi
```

- [ ] **Step 7: `command rm`** — replace all four `trap 'rm -f "$ERR" "$OUT" "$PROMPT"' EXIT` with `trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT` (rescue.md ×2, codex-rescue.md ×2). In `tests/commands.test.mjs` add to the rescue test: `assert.match(rescue, /trap 'command rm -f -- /); assert.match(agent, /trap 'command rm -f -- /); assert.doesNotMatch(rescue, /trap 'rm -f/); assert.doesNotMatch(agent, /trap 'rm -f/);`.

- [ ] **Step 8: Gate → `fail 0`; commit**

```bash
git add tests/fake-codex-fixture.mjs tests/runtime.test.mjs tests/commands.test.mjs .github/workflows/pull-request-ci.yml plugins/codex/commands/rescue.md plugins/codex/agents/codex-rescue.md
git commit -m "fix(broker,tests): idle self-terminate via #457; 2s idle timeout in tests; CI leak gate; command rm in traps" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```

---

### Task 2: Release v1.1.1

**Files:** `package.json`, `package-lock.json`, `plugins/codex/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` (via `npm run bump-version -- 1.1.1`), `CHANGELOG.md`.

- [ ] **Step 1:** `npm run bump-version -- 1.1.1 && npm run check-version` (lockfile name check included since v1.1.0).
- [ ] **Step 2: CHANGELOG** — insert above `## 1.1.0`:
```markdown
## 1.1.1 — 2026-08-28

- Broker idle self-terminate (upstream #457): the shared Codex runtime exits after 30 minutes without a connected client (`CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS` / `--idle-timeout`), so idle brokers and their app-server children no longer accumulate (#543).
- Test suite no longer leaks fake `codex app-server`/broker processes (2 s idle timeout in the test environment; CI fails if any `codex-plugin-test-*` process survives).
- Rescue shell blocks use `command rm -f --` in their cleanup trap (no noise from `rm` aliases such as `trash`).
```
- [ ] **Step 3:** gate, `npm run build`, `claude plugin validate . --strict`; commit `chore(release): v1.1.1`.

## Verification
1. `npm test` then `sleep 5; pgrep -f codex-plugin-test-` → nothing.
2. Real broker: run a companion `task` in a scratch repo with `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=5000`, wait 8 s → broker pid gone, its app-server gone.
3. `/codex:rescue …` in this session → no `trash:` line.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cbepx/codex-plugin-cc",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "1.1.0",
"version": "1.1.1",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
Expand Down
4 changes: 2 additions & 2 deletions plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Forwarding rules:
Launch (one Bash call):

```bash
trap 'rm -f "$ERR" "$OUT" "$PROMPT"' EXIT
trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT
ERR=$(mktemp); PROMPT=$(mktemp)
cat > "$PROMPT" <<'CODEX_PROMPT_<random>'
<request text>
Expand All @@ -43,7 +43,7 @@ echo "JOB=$JOB"
Wait and fetch the result (one Bash call, tool `timeout: 600000`). Set `JOB=<id>` literally as the first line, using the id you just read:

```bash
trap 'rm -f "$ERR" "$OUT" "$PROMPT"' EXIT
trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT
JOB=<id>
[[ "$JOB" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "invalid job id"; exit 1; }
OUT=$(mktemp); ERR=$(mktemp)
Expand Down
4 changes: 2 additions & 2 deletions plugins/codex/commands/rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The request prose and the runtime flags travel in two separate channels of the s
2a. Launch (one Bash call):

```bash
trap 'rm -f "$ERR" "$OUT" "$PROMPT"' EXIT
trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT
ERR=$(mktemp); PROMPT=$(mktemp)
cat > "$PROMPT" <<'CODEX_PROMPT_<random>'
<request text>
Expand All @@ -38,7 +38,7 @@ If this call exits non-zero, its output is the launch failure (Codex missing, un
2b. Wait and fetch the result (one Bash call, tool `timeout: 600000`). Set `JOB=<id>` literally as the first line, using the id you just read from 2a:

```bash
trap 'rm -f "$ERR" "$OUT" "$PROMPT"' EXIT
trap 'command rm -f -- "$ERR" "$OUT" "$PROMPT"' EXIT
JOB=<id>
[[ "$JOB" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "invalid job id"; exit 1; }
OUT=$(mktemp); ERR=$(mktemp)
Expand Down
107 changes: 103 additions & 4 deletions plugins/codex/scripts/app-server-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,39 @@ 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";
import { clearBrokerSession, loadBrokerSession } from "./lib/broker-lifecycle.mjs";

const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);

// Broker-side idle timeout. When no client has been connected for this long the
// broker self-terminates. This is the correctness backstop for stale ownership
// records in broker.json: a co-owning session that exits without running its
// SessionEnd hook (SIGKILL, OOM, crash, host reboot) or whose teardown skips the
// entry on lock contention leaves its sessionId behind forever, so no future hook
// will ever tear the broker down. Self-termination on idle is platform-independent
// and needs no PID/liveness signal, so it covers the abnormal-exit orphan, the
// dead-co-owner orphan, and the lock-contention skip in one mechanism. See #108,
// #380, and #450.
const IDLE_TIMEOUT_ENV = "CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS";
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;

// Resolve the idle timeout from the CLI flag, then the environment, then the
// default. Exactly `0` disables the timeout so the broker never self-terminates,
// which keeps the old behavior available for callers that manage lifecycle
// themselves; a negative or non-finite override is treated as garbage and falls
// back to the default.
function resolveIdleTimeoutMs(optionValue, env = process.env) {
const raw = optionValue ?? env[IDLE_TIMEOUT_ENV];
if (raw === undefined || raw === null || raw === "") {
return DEFAULT_IDLE_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 0) {
return DEFAULT_IDLE_TIMEOUT_MS;
}
return parsed;
}

function buildStreamThreadIds(method, params, result) {
const threadIds = new Set();
if (params?.threadId) {
Expand Down Expand Up @@ -48,11 +78,11 @@ function writePidFile(pidFile) {
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>]");
throw new Error("Usage: node scripts/app-server-broker.mjs serve --endpoint <value> [--cwd <path>] [--pid-file <path>] [--idle-timeout <ms>]");
}

const { options } = parseArgs(argv, {
valueOptions: ["cwd", "pid-file", "endpoint"]
valueOptions: ["cwd", "pid-file", "endpoint", "idle-timeout"]
});

if (!options.endpoint) {
Expand All @@ -63,13 +93,40 @@ async function main() {
const endpoint = String(options.endpoint);
const listenTarget = parseBrokerEndpoint(endpoint);
const pidFile = options["pid-file"] ? path.resolve(options["pid-file"]) : null;
const idleTimeoutMs = resolveIdleTimeoutMs(options["idle-timeout"]);
writePidFile(pidFile);

const appClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
let activeRequestSocket = null;
let activeStreamSocket = null;
let activeStreamThreadIds = null;
const sockets = new Set();
let idleTimer = null;
let shuttingDown = false;

function disarmIdleTimer() {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
}

// Arm the idle timer whenever the broker has no connected clients. A live
// client connection means the broker is still in use, so we only count down
// while idle and cancel the moment a client connects. When the timer fires we
// shut the broker down gracefully and exit.
function armIdleTimer() {
disarmIdleTimer();
if (idleTimeoutMs <= 0 || sockets.size > 0) {
return;
}
idleTimer = setTimeout(() => {
idleTimer = null;
shutdown(server)
.catch(() => {})
.finally(() => process.exit(0));
}, idleTimeoutMs);
}

function clearSocketOwnership(socket) {
if (activeRequestSocket === socket) {
Expand Down Expand Up @@ -99,12 +156,40 @@ async function main() {
}
}

// The ownership record in broker.json outlives the process unless we drop it:
// after an idle self-terminate a later SessionEnd hook would load it and
// signal a PID the OS may have recycled, and `status`/`reuseExistingBroker`
// would advertise or dial an endpoint nothing is listening on. Only clear a
// record that still points at this broker — a newer broker may have replaced
// us in it. The state dir derives from --cwd plus the inherited environment,
// exactly as it did in the process that spawned us.
function clearOwnSessionRecord() {
try {
if (loadBrokerSession(cwd)?.endpoint === endpoint) {
clearBrokerSession(cwd);
}
} catch {
// Best-effort: never block shutdown on state-file cleanup.
}
}

// Closing the app-server child can take a while. Stop listening before the
// first await instead of after it: a client accepted in that window would be
// served the broker-local `initialize` and then fail its first real RPC with
// "codex app-server client is closed", which callers do not retry.
async function shutdown(server) {
if (shuttingDown) {
return;
}
shuttingDown = true;
disarmIdleTimer();
clearOwnSessionRecord();
const serverClosed = new Promise((resolve) => server.close(resolve));
for (const socket of sockets) {
socket.end();
}
await appClient.close().catch(() => {});
await new Promise((resolve) => server.close(resolve));
await serverClosed;
if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) {
fs.unlinkSync(listenTarget.path);
}
Expand All @@ -116,7 +201,14 @@ async function main() {
appClient.setNotificationHandler(routeNotification);

const server = net.createServer((socket) => {
if (shuttingDown) {
// Already accepted before the listener finished closing: reset it so the
// client retries or reports a connection error instead of half-working.
socket.destroy();
return;
}
sockets.add(socket);
disarmIdleTimer();
socket.setEncoding("utf8");
let buffer = "";

Expand Down Expand Up @@ -225,11 +317,13 @@ async function main() {
socket.on("close", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
armIdleTimer();
});

socket.on("error", () => {
sockets.delete(socket);
clearSocketOwnership(socket);
armIdleTimer();
});
});

Expand All @@ -243,7 +337,12 @@ async function main() {
process.exit(0);
});

server.listen(listenTarget.path);
server.listen(listenTarget.path, () => {
// Start counting down immediately: a broker that is spawned but never
// receives a client (or whose only client connects briefly during the
// readiness probe) must still self-terminate instead of lingering.
armIdleTimer();
});
}

main().catch((error) => {
Expand Down
Loading