diff --git a/README.md b/README.md index 4243609..0c7ede9 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ authmux parallel --install # Write aliases to shell rc file Each profile gets its own config directory at `~/.claude-accounts/`. Shell aliases/functions set `CLAUDE_CONFIG_DIR` before launching `claude`, so each instance uses isolated credentials, settings, and history. `authmux parallel --install` writes Bash/Zsh aliases or Fish autoload functions based on `$SHELL`. Login and logout commands bypass Cue so refreshed OAuth tokens are written back to the right account directory. Generated login wrappers use `authmux parallel --login --fresh --require-distinct`, which backs up stale Claude auth first and rejects a login that resolves to another configured profile. Run profiles in separate terminal tabs or tmux panes for true parallel usage. -`authmux parallel --list` warns when two profiles contain the same Claude credentials or the same Claude OAuth account UUID. Those profiles are not independent: logging in or out of one can invalidate the other because Claude sees them as the same account session. Re-run `authmux parallel --login --fresh --require-distinct` and choose a different Anthropic account for any profile that should have its own subscription/session. +`authmux parallel --list` warns when two profiles contain the same Claude credentials or the same fresh Claude OAuth account UUID. Those profiles are not independent: logging in or out of one can invalidate the other because Claude sees them as the same account session. Re-run `authmux parallel --login --fresh --require-distinct` and choose a different Anthropic account for any profile that should have its own subscription/session. If `.claude.json` is older than the active credentials, Authmux marks it as `claudeAccountState=stale` and does not use that stale UUID for duplicate-account detection. ### Notes diff --git a/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/.openspec.yaml b/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/notes.md b/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/notes.md new file mode 100644 index 0000000..a6e9237 --- /dev/null +++ b/openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/notes.md @@ -0,0 +1,16 @@ +# agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52 (minimal / T1) + +Branch: `agent/codex/ignore-stale-claude-account-metadata-2026-06-29-08-52` + +Ignore stale `.claude.json` OAuth account UUID metadata when newer `.credentials.json` exists, so different credentials are not falsely flagged as duplicate accounts. + +## Handoff + +- Handoff: change=`agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52`; branch=`agent/codex/ignore-stale-claude-account-metadata-2026-06-29-08-52`; scope=`src/commands/parallel.ts src/tests/json-parity.test.ts README.md`; action=`finish stale Claude metadata handling and refresh live build/wrappers after merge`. +- Copy prompt: Continue `agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52` on branch `agent/codex/ignore-stale-claude-account-metadata-2026-06-29-08-52`. Work inside the existing sandbox, review `openspec/changes/agent-codex-ignore-stale-claude-account-metadata-2026-06-29-08-52/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/ignore-stale-claude-account-metadata-2026-06-29-08-52 --base main --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/codex/ignore-stale-claude-account-metadata-2026-06-29-08-52 --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`). diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index 16ff2ac..6ce59f3 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -36,6 +36,7 @@ interface ParallelProfileEntry { skillProfile: string; cueProfile: string; credentialsPresent: boolean; + claudeAccountStateStale: boolean; credentialsDuplicateOf?: string; claudeAccountDuplicateOf?: string; } @@ -154,18 +155,33 @@ function hashFileIfPresent(file: string): string | undefined { return hash.digest("hex"); } -function readClaudeAccountUuid(name: string): string | undefined { +function statFileIfPresent(file: string): fs.Stats | undefined { + return fs.statSync(file, { throwIfNoEntry: false }) ?? undefined; +} + +function readClaudeAccountUuid(name: string, credentialsMtimeMs?: number): { + uuid?: string; + stale: boolean; +} { const file = claudeStatePathForProfile(name); - if (!fs.existsSync(file)) return undefined; + const stateStat = statFileIfPresent(file); + if (!stateStat) return { stale: false }; + + if (credentialsMtimeMs !== undefined && stateStat.mtimeMs < credentialsMtimeMs) { + return { stale: true }; + } try { const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { oauthAccount?: { accountUuid?: unknown }; }; const uuid = parsed.oauthAccount?.accountUuid; - return typeof uuid === "string" && uuid.length > 0 ? uuid : undefined; + return { + uuid: typeof uuid === "string" && uuid.length > 0 ? uuid : undefined, + stale: false, + }; } catch { - return undefined; + return { stale: false }; } } @@ -186,8 +202,10 @@ function buildProfileEntries(profiles = getProfiles()): ParallelProfileEntry[] { const seenClaudeAccountUuids = new Map(); return profiles.map((p) => { - const credentialHash = hashFileIfPresent(credentialsPathForProfile(p)); - const claudeAccountUuid = readClaudeAccountUuid(p); + const credentialsPath = credentialsPathForProfile(p); + const credentialsStat = statFileIfPresent(credentialsPath); + const credentialHash = credentialsStat ? hashFileIfPresent(credentialsPath) : undefined; + const claudeAccount = readClaudeAccountUuid(p, credentialsStat?.mtimeMs); return { name: p, @@ -195,8 +213,9 @@ function buildProfileEntries(profiles = getProfiles()): ParallelProfileEntry[] { skillProfile: readSkillProfile(p) ?? "base", cueProfile: readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE, credentialsPresent: Boolean(credentialHash), + claudeAccountStateStale: claudeAccount.stale, credentialsDuplicateOf: duplicateOwner(credentialHash, seenCredentialHashes, p), - claudeAccountDuplicateOf: duplicateOwner(claudeAccountUuid, seenClaudeAccountUuids, p), + claudeAccountDuplicateOf: duplicateOwner(claudeAccount.uuid, seenClaudeAccountUuids, p), }; }); } @@ -411,6 +430,7 @@ export default class ClaudeParallel extends Command { const duplicateBits = [ p.credentialsDuplicateOf ? `credentialsDuplicateOf=${p.credentialsDuplicateOf}` : undefined, p.claudeAccountDuplicateOf ? `claudeAccountDuplicateOf=${p.claudeAccountDuplicateOf}` : undefined, + p.claudeAccountStateStale ? "claudeAccountState=stale" : undefined, ].filter(Boolean); const duplicateSuffix = duplicateBits.length ? ` ${duplicateBits.join(" ")}` : ""; this.log( diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 1f7dc8c..869b486 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -240,6 +240,55 @@ test("parallel --list flags duplicate Claude credentials", async () => { }); }); +test("parallel --list ignores stale Claude account UUID metadata", async () => { + await withSandbox(async (env) => { + for (const profile of ["account1", "account2"]) { + const added = runCli(["parallel", "--add", profile, "--json"], env); + assert.equal(added.status, 0, added.stderr); + } + + const accountsDir = path.join(env.HOME as string, ".claude-accounts"); + const sharedClaudeState = JSON.stringify({ + oauthAccount: { + accountUuid: "same-claude-account", + }, + }); + const account1Dir = path.join(accountsDir, "account1"); + const account2Dir = path.join(accountsDir, "account2"); + await fsp.writeFile( + path.join(account1Dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "account1-token" } }), + ); + await fsp.writeFile(path.join(account1Dir, ".claude.json"), sharedClaudeState); + await fsp.writeFile(path.join(account2Dir, ".claude.json"), sharedClaudeState); + const staleTime = new Date("2020-01-01T00:00:00Z"); + await fsp.utimes(path.join(account2Dir, ".claude.json"), staleTime, staleTime); + await fsp.writeFile( + path.join(account2Dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "account2-token" } }), + ); + + const listed = runCli(["parallel", "--list", "--json"], env); + assert.equal(listed.status, 0, listed.stderr); + + const parsed = JSON.parse(listed.stdout.trim()) as { + ok: true; + data: { + profiles: Array<{ + name: string; + claudeAccountStateStale: boolean; + credentialsDuplicateOf?: string; + claudeAccountDuplicateOf?: string; + }>; + }; + }; + const account2 = parsed.data.profiles.find((p) => p.name === "account2"); + assert.equal(account2?.claudeAccountStateStale, true); + assert.equal(account2?.credentialsDuplicateOf, undefined); + assert.equal(account2?.claudeAccountDuplicateOf, undefined); + }); +}); + test("parallel --login can reject duplicate Claude accounts", async () => { await withSandbox(async (env) => { for (const profile of ["account1", "account2"]) {