SessionEnd never reclaims the app-server broker when the Claude session cwd is not a git repository
Environment
- codex plugin 1.0.4 (Claude Code plugin)
- macOS 26 (Darwin 25.6.0), Node 25.6.1
Summary
app-server-broker.mjs processes accumulate indefinitely. On my machine 11 brokers had been running for up to 10 days, holding ~1.2 GB RSS together with 30 child playwright-mcp processes. They are never reclaimed because the SessionEnd hook looks for broker.json under a different directory than the one it was written to.
Root cause
The broker session file is written under the git repository root of the cwd that codex ran in:
lib/broker-lifecycle.mjs:72—resolveBrokerStateFile(cwd)→path.join(resolveStateDir(cwd), "broker.json")lib/state.mjs:29—resolveStateDir(cwd)→resolveWorkspaceRoot(cwd)lib/workspace.mjs:3—resolveWorkspaceRoot(cwd)→ensureGitRepository(cwd), falling back tocwdwhen it is not a git repo
But SessionEnd looks it up with the Claude session's cwd:
session-lifecycle-hook.mjs:82—const cwd = input.cwd || process.cwd();session-lifecycle-hook.mjs:84—loadBrokerSession(cwd)
When the Claude session runs from a parent workspace directory that is not itself a git repository, while codex runs against git repositories nested inside it, the two paths never agree. loadBrokerSession returns null, sendBrokerShutdown is skipped, and teardownBrokerSession is called with pid: null, so nothing is killed. Since the broker is spawned detached: true + unref() (lib/broker-lifecycle.mjs:64-67), it is reparented to launchd and survives forever.
The BROKER_ENDPOINT_ENV fallback at session-lifecycle-hook.mjs:85-91 does not help: handleSessionStart only exports CODEX_COMPANION_SESSION_ID and CLAUDE_PLUGIN_DATA, so the hook process never sees a broker endpoint.
Verification
// plugin data env set, otherwise unmodified 1.0.4
loadBrokerSession("/Users/me/workspace") // → null (Claude session cwd, not a git repo)
loadBrokerSession("/Users/me/workspace/project") // → { pid: 26667, ... } (where codex actually ran)Observed state before cleanup: 11 live brokers, each registered under a different project's state dir; 15 broker.json files total; the oldest broker had been running 10 days.
Reproduction
- Use a parent directory that is not a git repository as the Claude Code working directory (e.g.
~/workspace), containing git repositories (e.g.~/workspace/project). - Start a Claude Code session there.
- Invoke codex so that it runs against
~/workspace/project. - Confirm the registration landed under the project:
ls ~/.claude/plugins/data/codex-openai-codex/state/project-*/broker.json - End the Claude session.
pgrep -f app-server-broker.mjs— the broker is still alive.- Repeat; brokers accumulate one per project, indefinitely.
Secondary issue (code inspection only, not reproduced)
lib/app-server.mjs:340 calls ensureBrokerSession(cwd, { env: options.env }) without killProcess. If a recorded broker is found but its endpoint is unreachable, ensureBrokerSession tears it down at lib/broker-lifecycle.mjs:119-129 — but teardownBrokerSession only kills when a killProcess callback is supplied (:174). The stale process is therefore left running while broker.json is overwritten with the new broker, making the old one unreachable for any later cleanup.
Suggested fixes
- Key broker registration by owning session id, or keep a registry the SessionEnd hook can read regardless of cwd, so shutdown does not depend on cwd agreeing.
- Alternatively, on SessionEnd sweep every
broker.jsonunder the plugin data state root and shut down those owned by the ending session. - Pass
killProcess: terminateProcessTreeatlib/app-server.mjs:340so the stale-broker path inensureBrokerSessioncan actually terminate it.
Two things that may help whoever picks this up:
- The owning session is already recorded in the broker process environment. A live broker carries
CODEX_COMPANION_SESSION_IDandCLAUDE_PID(verified withps -wwwE -o command= <pid>), so a cwd-independent sweep can establish ownership without adding any new bookkeeping — including for brokers that were orphaned by earlier versions. - Brokers are shared per workspace, not per session (
lib/state.mjs:29→resolveWorkspaceRoot, andensureBrokerSessionreuses a reachable endpoint atlib/broker-lifecycle.mjs:113-116). So a fix must not shut down a broker another live session is still using: checkstate.jsonforrunning/queuedjobs whose pid is still alive before terminating. Note thatstatusstaysrunningfor long-dead jobs, so the status field alone is not sufficient.
Happy to test a patch.
Source: openai/codex-plugin-cc