fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that outlives its TUIs and keeps the login it started with, so writing a new auth.json changed nothing: open sessions and even new ones stayed on the old (out-of-quota) account. After a switch, restart that CODEX_HOME's daemon when one runs; open TUIs reconnect to it on their own (verified on 0.164.0 with two keys). The response says so (following), and the toast tells the user running sessions follow.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.39 (2026-10-29) — a display name may not read as another account's (ENAMETAKEN)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.39 (2026-10-29) — a Codex profile switch restarts Codex's app-server daemon so sessions follow._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
const r = await fn();
|
||||
toast.success(typeof success === "function" ? success(r) : success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
(r) =>
|
||||
t(
|
||||
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||
{
|
||||
name: p.label,
|
||||
tool: label,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
@@ -1396,6 +1396,7 @@
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -1384,6 +1384,7 @@
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
|
||||
@@ -1401,7 +1401,7 @@ export const api = {
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
|
||||
+1
-1
@@ -3036,7 +3036,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
|
||||
DELETE /api/tool-profiles/:tool Body: { name }
|
||||
```
|
||||
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
|
||||
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
||||
|
||||
const log = path.join(ROOT, "codex-argv.log");
|
||||
const bin = path.join(ROOT, "fake-codex.sh");
|
||||
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
||||
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
||||
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||
fs.writeFileSync(sock, "");
|
||||
process.env.AGENTHUB_CODEX_BIN = bin;
|
||||
try {
|
||||
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
||||
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(
|
||||
fs.readFileSync(log, "utf8").trim(),
|
||||
`${process.env.CODEX_HOME} app-server daemon restart`
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_CODEX_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* swap it back in later, touching nothing but that login.
|
||||
*
|
||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||
* key). Config, sessions, skills and history stay where they are.
|
||||
* key). Config, sessions, skills and history stay where they are. A
|
||||
* switch then restarts Codex's shared app-server daemon when one runs,
|
||||
* so running and new sessions follow the new login (`follow`).
|
||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||
@@ -85,6 +87,15 @@ const codex = {
|
||||
writeJson(codexAuthFile(), secret);
|
||||
return [codexAuthFile()];
|
||||
},
|
||||
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
||||
// outlives every TUI and holds the login it started with: a new auth.json
|
||||
// reached neither a running session nor a NEW one, which kept the old
|
||||
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
||||
// login, and every open TUI reconnects to it on its own (verified on
|
||||
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
||||
follow() {
|
||||
return restartCodexDaemon();
|
||||
},
|
||||
identify(auth) {
|
||||
if (auth.tokens && auth.tokens.id_token) {
|
||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||
@@ -107,6 +118,26 @@ const codex = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
||||
const codexDaemonSocket = () =>
|
||||
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
||||
|
||||
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
||||
function restartCodexDaemon() {
|
||||
if (!fs.existsSync(codexDaemonSocket())) return false;
|
||||
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
||||
if (!bin) return false;
|
||||
cp.execFile(
|
||||
bin,
|
||||
["app-server", "daemon", "restart"],
|
||||
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
||||
(e) => {
|
||||
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
|
||||
}
|
||||
const target = readProfile(tool, name);
|
||||
const written = a.write(target.secret, live);
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
// `following`: running sessions take the new login without a restart of their own.
|
||||
const following = typeof a.follow === "function" ? a.follow() : false;
|
||||
return { ok: true, name, savedOutgoing, written, following };
|
||||
}
|
||||
|
||||
function deleteProfile(tool, name) {
|
||||
|
||||
@@ -489,6 +489,11 @@ const paths = {
|
||||
items: { type: "string" },
|
||||
description: "Files the switch wrote.",
|
||||
},
|
||||
following: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Switched."
|
||||
|
||||
Reference in New Issue
Block a user