diff --git a/CLAUDE.md b/CLAUDE.md index 38a25942..92db06f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/client/src/components/settings/ToolProfilesSettings.tsx b/client/src/components/settings/ToolProfilesSettings.tsx index 7f76b1b0..fbc940d7 100644 --- a/client/src/components/settings/ToolProfilesSettings.tsx +++ b/client/src/components/settings/ToolProfilesSettings.tsx @@ -262,11 +262,11 @@ function LabelEdit({ function useRunner(load: () => Promise) { const [busy, setBusy] = useState(null); const run = useCallback( - async (key: string, fn: () => Promise, success: string) => { + async (key: string, fn: () => Promise, 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
{parts.join(" · ")}
; } diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 35f1fa5c..a73654ec 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -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": { diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 234a9513..018eba3f 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -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": { diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index b99771e4..93c7b5cb 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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 }) } ), diff --git a/docs/API.md b/docs/API.md index 465ffff8..9fffda9d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 `/agenthub-profiles//` (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 `/agenthub-profiles//` (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 diff --git a/server/__tests__/tool-profiles.test.js b/server/__tests__/tool-profiles.test.js index a888cf6b..5cf91d73 100644 --- a/server/__tests__/tool-profiles.test.js +++ b/server/__tests__/tool-profiles.test.js @@ -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"); diff --git a/server/lib/tool-profiles.js b/server/lib/tool-profiles.js index 3dc2f291..94b844e5 100644 --- a/server/lib/tool-profiles.js +++ b/server/lib/tool-profiles.js @@ -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) { diff --git a/server/openapi-extra/tools-more.js b/server/openapi-extra/tools-more.js index 47408fd0..463c7adb 100644 --- a/server/openapi-extra/tools-more.js +++ b/server/openapi-extra/tools-more.js @@ -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."