SessionEnd never reclaims the app-server broker when the Claude session cwd is not a git repository

Author: kurehoCreated Sep 16, 2026Updated Sep 16, 2026

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:72resolveBrokerStateFile(cwd)path.join(resolveStateDir(cwd), "broker.json")
  • lib/state.mjs:29resolveStateDir(cwd)resolveWorkspaceRoot(cwd)
  • lib/workspace.mjs:3resolveWorkspaceRoot(cwd)ensureGitRepository(cwd), falling back to cwd when it is not a git repo

But SessionEnd looks it up with the Claude session's cwd:

  • session-lifecycle-hook.mjs:82const cwd = input.cwd || process.cwd();
  • session-lifecycle-hook.mjs:84loadBrokerSession(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

javascript
// 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

  1. 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).
  2. Start a Claude Code session there.
  3. Invoke codex so that it runs against ~/workspace/project.
  4. Confirm the registration landed under the project: ls ~/.claude/plugins/data/codex-openai-codex/state/project-*/broker.json
  5. End the Claude session.
  6. pgrep -f app-server-broker.mjs — the broker is still alive.
  7. 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

  1. 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.
  2. Alternatively, on SessionEnd sweep every broker.json under the plugin data state root and shut down those owned by the ending session.
  3. Pass killProcess: terminateProcessTree at lib/app-server.mjs:340 so the stale-broker path in ensureBrokerSession can 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_ID and CLAUDE_PID (verified with ps -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:29resolveWorkspaceRoot, and ensureBrokerSession reuses a reachable endpoint at lib/broker-lifecycle.mjs:113-116). So a fix must not shut down a broker another live session is still using: check state.json for running/queued jobs whose pid is still alive before terminating. Note that status stays running for long-dead jobs, so the status field alone is not sufficient.

Happy to test a patch.