Skip to content
Open
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,74 @@ Your configuration will be picked up based on:

Check out the Codex docs for more [configuration options](https://developers.openai.com/codex/config-reference).

### Multiple Accounts (`CODEX_HOME`)

The Codex CLI keeps everything account-specific — `auth.json`, `config.toml`,
session history — inside `CODEX_HOME` (default `~/.codex`). Pointing it at a
different directory gives you a fully independent account profile, which is
useful when one account hits its 5h/weekly usage window and another has idle
capacity (for example, a Business workspace where some seats belong to
teammates who don't use Codex day-to-day).

#### Setup

Add one alias per account to your shell profile:

```bash
# ~/.zshrc / ~/.bashrc
alias codex-main='CODEX_HOME=$HOME/.codex codex'
alias codex-alice='CODEX_HOME=$HOME/.codex-alice codex'
alias codex-bob='CODEX_HOME=$HOME/.codex-bob codex'
```

Then:

```bash
source ~/.zshrc
```

Log each account in once, into its own home:

```bash
codex-main login # default account -> ~/.codex
codex-alice login # second account -> ~/.codex-alice
codex-bob login # third account -> ~/.codex-bob
```

From then on, each alias is a fully independent Codex:

```bash
codex-main
codex-alice
codex-bob
```

> **Note:** the aliases are pure convenience for you in interactive shells —
> switching accounts by typing `codex-alice` instead of
> `CODEX_HOME=$HOME/.codex-alice codex`. The only **mandatory** step is logging
> each account in once, into its own home (`codex-alice login`). The plugin
> never uses your aliases: it reads the `CODEX_HOME` environment variable
> directly, as described below.

#### How the plugin handles it

The plugin honors the same variable: invoking the companion with a different
`CODEX_HOME` runs that turn on that account. The per-workspace broker is
account-aware — it restarts when the account changes **and the broker is idle**;
if it is mid-task for the previous account, the new call runs on a directly
spawned app server instead (in-flight work is never interrupted) and the
rotation happens on the next idle call. Same-account calls keep reusing the
warm broker.

Two gotchas worth knowing:

- **Non-interactive shells** (agents, CI, hooks) don't load your shell aliases —
always use the explicit `CODEX_HOME=... codex ...` form there.
- **Placement in compound commands**: an env assignment only applies to the
command it directly prefixes. `CODEX_HOME=... cd dir && codex ...` sets the
variable for `cd` and silently runs Codex on the default account. Correct:
`cd dir && CODEX_HOME=... codex ...`.

### 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.
Expand Down
20 changes: 19 additions & 1 deletion plugins/codex/scripts/app-server-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,29 @@ async function main() {
}

if (message.id !== undefined && message.method === "broker/shutdown") {
send(socket, { id: message.id, result: {} });
// `ifIdle` makes the shutdown atomic with the busy check (single
// event loop): a turn that started after a caller's idle probe makes
// the broker refuse, instead of dropping that in-flight work.
const busyNow = Boolean(activeRequestSocket || activeStreamSocket);
if (message.params?.ifIdle && busyNow) {
send(socket, { id: message.id, result: { shutdown: false, busy: true } });
continue;
}
send(socket, { id: message.id, result: { shutdown: true } });
await shutdown(server);
process.exit(0);
}

// Answered before the busy gate so callers can probe safely while a
// turn is in flight (used by account-aware broker rotation).
if (message.id !== undefined && message.method === "broker/status") {
send(socket, {
id: message.id,
result: { busy: Boolean(activeRequestSocket || activeStreamSocket) }
});
continue;
}

if (message.id === undefined) {
continue;
}
Expand Down
124 changes: 114 additions & 10 deletions plugins/codex/scripts/lib/broker-lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,98 @@ export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) {
return false;
}

export async function sendBrokerShutdown(endpoint) {
await new Promise((resolve) => {
// Keep in sync with BROKER_BUSY_RPC_CODE in lib/app-server.mjs (importing it
// here would create a circular dependency: app-server.mjs imports this module).
const BROKER_BUSY_RPC_CODE = -32001;

/**
* Probe whether the broker is currently serving a request or streaming turn.
* Uses the `broker/status` RPC (answered before the busy gate). Brokers from
* older plugin versions don't implement it: when busy their gate rejects the
* probe with BROKER_BUSY_RPC_CODE (busy), and when idle they forward it to the
* app server which rejects the unknown method (idle) — both interpretable.
* Timeouts are treated as busy so an unresponsive broker is never killed
* mid-turn by account rotation.
*/
export async function isBrokerBusy(endpoint, timeoutMs = 1500) {
return await new Promise((resolve) => {
const socket = connectToEndpoint(endpoint);
let buffer = "";
let settled = false;
const finish = (busy) => {
if (!settled) {
settled = true;
clearTimeout(timer);
socket.destroy();
resolve(busy);
}
};
const timer = setTimeout(() => finish(true), timeoutMs);
socket.setEncoding("utf8");
socket.on("connect", () => {
socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params: {} })}\n`);
socket.write(`${JSON.stringify({ id: 1, method: "broker/status", params: {} })}\n`);
});
socket.on("data", () => {
socket.end();
resolve();
socket.on("data", (chunk) => {
buffer += chunk;
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
return;
}
try {
const message = JSON.parse(buffer.slice(0, newlineIndex));
if (message.error) {
finish(message.error.code === BROKER_BUSY_RPC_CODE);
return;
}
finish(Boolean(message.result?.busy));
} catch {
finish(true);
}
});
socket.on("error", resolve);
socket.on("close", resolve);
socket.on("error", () => finish(true));
socket.on("close", () => finish(true));
});
}

/**
* Ask the broker to shut down. With `ifIdle: true` the broker refuses when a
* request/stream is in flight (atomic with its busy state — see
* app-server-broker.mjs); the promise then resolves `false`. Resolves `true`
* when the broker shut down (or on legacy brokers that ignore the param and
* reply with an empty result).
*/
export async function sendBrokerShutdown(endpoint, { ifIdle = false } = {}) {
return await new Promise((resolve) => {
const socket = connectToEndpoint(endpoint);
let buffer = "";
let settled = false;
const finish = (didShutdown) => {
if (!settled) {
settled = true;
socket.end();
resolve(didShutdown);
}
};
socket.setEncoding("utf8");
socket.on("connect", () => {
const params = ifIdle ? { ifIdle: true } : {};
socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params })}\n`);
});
socket.on("data", (chunk) => {
buffer += chunk;
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) {
return;
}
try {
const message = JSON.parse(buffer.slice(0, newlineIndex));
finish(message.result?.shutdown !== false);
} catch {
finish(true);
}
});
socket.on("error", () => finish(true));
socket.on("close", () => finish(true));
});
}

Expand Down Expand Up @@ -111,12 +190,36 @@ async function isBrokerEndpointReady(endpoint) {
}

export async function ensureBrokerSession(cwd, options = {}) {
// Account-aware reuse: the broker (and the `codex app-server` it manages)
// inherits CODEX_HOME once, at spawn time, so a live broker started under one
// account would otherwise silently serve every later call in this workspace —
// ignoring the caller's CODEX_HOME and breaking multi-account fallback.
// When the caller's CODEX_HOME differs from the one the session was created
// with, shut the old broker down gracefully and start a fresh one.
const desiredCodexHome = (options.env ?? process.env).CODEX_HOME ?? "";
const existing = loadBrokerSession(cwd);
if (existing && (await isBrokerEndpointReady(existing.endpoint))) {
const existingReady = existing ? await isBrokerEndpointReady(existing.endpoint) : false;
if (existing && existingReady && (existing.codexHome ?? "") === desiredCodexHome) {
return existing;
Comment on lines +202 to 203

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply account checks to reused broker status calls

This account match only applies to callers that go through ensureBrokerSession; callers using reuseExistingBroker still read loadBrokerSession(cwd)?.endpoint directly in CodexAppServerClient.connect. In practice, after account A has started a shared broker, running /codex:setup or another auth/status path with CODEX_HOME set to account B will connect to A's broker and report A's account/read/config/read results, so setup can say the wrong account is logged in even though task/review calls would rotate or fall back for B.

Useful? React with 👍 / 👎.

}

if (existing) {
if (existingReady && (existing.codexHome ?? "") !== desiredCodexHome) {
// Never kill in-flight work on account rotation. The probe also covers
// legacy brokers (their busy gate rejects it with BROKER_BUSY_RPC_CODE);
// the `ifIdle` shutdown closes the probe→shutdown race on current
// brokers (a turn that starts in between makes the broker refuse).
// On either busy signal: return null so this call falls back to a
// directly spawned app server with the caller's env, and the rotation
// happens on the next call that finds the broker idle.
if (await isBrokerBusy(existing.endpoint)) {
return null;
}
const didShutdown = await sendBrokerShutdown(existing.endpoint, { ifIdle: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid ifIdle shutdowns against legacy brokers

When the saved session belongs to a pre-fix broker (no codexHome in broker.json), this sends the new ifIdle parameter to a process that does not implement it; I checked the parent app-server-broker.mjs, where broker/shutdown is handled unconditionally before the busy gate. The fresh evidence is the legacy reuse path here (existing.codexHome ?? "") plus the new ifIdle call: if an account-A command starts after the status probe but before this shutdown during an upgrade, the old broker ignores ifIdle and the account-B rotation still kills that in-flight turn.

Useful? React with 👍 / 👎.

if (!didShutdown) {
return null;
}
}
teardownBrokerSession({
endpoint: existing.endpoint ?? null,
pidFile: existing.pidFile ?? null,
Expand Down Expand Up @@ -164,7 +267,8 @@ export async function ensureBrokerSession(cwd, options = {}) {
pidFile,
logFile,
sessionDir,
pid: child.pid ?? null
pid: child.pid ?? null,
codexHome: desiredCodexHome
};
saveBrokerSession(cwd, session);
return session;
Expand Down
Loading