Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ authmux parallel --install # Write aliases to shell rc file

Each profile gets its own config directory at `~/.claude-accounts/<name>`. 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 <name> --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 <name> --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 <name> --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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-29
Original file line number Diff line number Diff line change
@@ -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`).
34 changes: 27 additions & 7 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface ParallelProfileEntry {
skillProfile: string;
cueProfile: string;
credentialsPresent: boolean;
claudeAccountStateStale: boolean;
credentialsDuplicateOf?: string;
claudeAccountDuplicateOf?: string;
}
Expand Down Expand Up @@ -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 };
}
}

Expand All @@ -186,17 +202,20 @@ function buildProfileEntries(profiles = getProfiles()): ParallelProfileEntry[] {
const seenClaudeAccountUuids = new Map<string, string>();

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,
configDir: path.join(CLAUDE_PARALLEL_DIR, p),
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),
};
});
}
Expand Down Expand Up @@ -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(
Expand Down
49 changes: 49 additions & 0 deletions src/tests/json-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Expand Down
Loading