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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-09
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# agent-codex-fix-account-persistence-2026-06-09-10-05 (minimal / T1)

Branch: `agent/codex/fix-account-persistence-2026-06-09-10-05`

Fix account isolation so per-terminal Codex sessions keep their selected authmux
account and Claude parallel aliases launch with per-account `CLAUDE_CONFIG_DIR`
plus an explicit Cue profile.

## Handoff

- Handoff: change=`agent-codex-fix-account-persistence-2026-06-09-10-05`; branch=`agent/codex/fix-account-persistence-2026-06-09-10-05`; scope=`src/commands/parallel.ts, src/lib/config/login-hook.ts, scripts/postinstall-login-hook.cjs, tests`; action=`finish via PR after verification`.
- Verification: `npm run build`; `node dist/tests/login-hook.test.js`; `node dist/tests/json-parity.test.js` (escalated because sandbox blocks child node spawn); live smoke `claude-account1 --version`, `claude-account2 --version`.
- Live config: backed up `/home/deadpool/.bashrc` to `/home/deadpool/.bashrc.authmux-backup-20260609-0814`, refreshed Claude aliases, and patched the active `codex()` shell wrapper to use `authmux` session restore/sync.
- Full suite note: `npm test` had 192/195 passing; only `skills-profile` tests failed because `/home/deadpool/Documents/soul` is absent in this environment.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/codex/fix-account-persistence-2026-06-09-10-05 --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`).
16 changes: 15 additions & 1 deletion scripts/postinstall-login-hook.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,21 @@ function renderHookBlock() {
" [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
" printf '\\033[>4m\\033[<u\\033[?2026l\\033[?1004l\\033[?1l\\033[?2004l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033[?1015l\\033[?1049l\\033[0m\\033[?25h\\033[H\\033>' >\"$__tty_target\" 2>/dev/null || true",
"}",
"__authmux_ensure_session_key() {",
" if [[ -n \"${CODEX_AUTH_SESSION_KEY:-}\" ]]; then",
" return 0",
" fi",
" local __authmux_tty=\"${TTY:-}\"",
" if [[ -z \"$__authmux_tty\" ]] && command -v tty >/dev/null 2>&1; then",
" __authmux_tty=\"$(tty 2>/dev/null || true)\"",
" fi",
" if [[ -z \"$__authmux_tty\" || \"$__authmux_tty\" == \"not a tty\" ]]; then",
" __authmux_tty=\"ppid:${PPID:-$$}\"",
" fi",
" export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\"",
"}",
"codex() {",
" __authmux_ensure_session_key",
" if command -v authmux >/dev/null 2>&1; then",
" command authmux restore-session >/dev/null 2>&1 || true",
" command authmux skills activate-current --agent codex >/dev/null 2>&1 || true",
Expand Down Expand Up @@ -119,7 +133,7 @@ async function maybeInstallHook() {
}

if (rc.includes(MARK_START) && rc.includes(MARK_END)) {
const refreshed = normalizeRcContents(rc.replace(hookBlockRegex(), `\n${renderHookBlock()}\n`));
const refreshed = normalizeRcContents(rc.replace(hookBlockRegex(), () => `\n${renderHookBlock()}\n`));
if (refreshed !== normalizeRcContents(rc)) {
await fs.writeFile(rcPath, refreshed, "utf8");
process.stdout.write(`\nUpdated shell hook in ${rcPath}. Restart terminal or run: source ${rcPath}\n`);
Expand Down
54 changes: 40 additions & 14 deletions src/commands/parallel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {

const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts");
const SKILL_PROFILE_FILE = ".authmux-skill-profile";
const CUE_PROFILE_FILE = ".authmux-cue-profile";
const DEFAULT_CUE_PROFILE = "core";

function getProfiles(): string[] {
if (!fs.existsSync(CLAUDE_PARALLEL_DIR)) return [];
Expand Down Expand Up @@ -45,6 +47,18 @@ function writeSkillProfile(name: string, skillProfile: string): void {
fs.writeFileSync(file, `${skillProfile.trim()}\n`);
}

function readCueProfile(name: string): string | undefined {
const file = path.join(CLAUDE_PARALLEL_DIR, name, CUE_PROFILE_FILE);
if (!fs.existsSync(file)) return undefined;
const profile = fs.readFileSync(file, "utf8").trim();
return profile.length > 0 ? profile : undefined;
}

function writeCueProfile(name: string, cueProfile: string): void {
const file = path.join(CLAUDE_PARALLEL_DIR, name, CUE_PROFILE_FILE);
fs.writeFileSync(file, `${cueProfile.trim()}\n`);
}

export default class ClaudeParallel extends Command {
static description = "Manage parallel Claude Code accounts via CLAUDE_CONFIG_DIR";

Expand All @@ -55,6 +69,7 @@ export default class ClaudeParallel extends Command {
install: Flags.boolean({ description: "Install aliases into shell rc file" }),
list: Flags.boolean({ char: "l", description: "List profiles" }),
"skill-profile": Flags.string({ description: "Soul skill profile for this Claude profile" }),
"cue-profile": Flags.string({ description: "Cue profile used by generated claude-<name> aliases" }),
json: Flags.boolean({
description: "Emit a single JSON envelope to stdout (Theme X4).",
default: false,
Expand All @@ -77,7 +92,7 @@ export default class ClaudeParallel extends Command {
this.jsonMode = Boolean(flags.json);

if (flags.add) {
this.addProfile(flags.add, flags["skill-profile"]);
this.addProfile(flags.add, flags["skill-profile"], flags["cue-profile"]);
} else if (flags.remove) {
this.removeProfile(flags.remove);
} else if (flags.install) {
Expand All @@ -89,7 +104,7 @@ export default class ClaudeParallel extends Command {
}
}

private addProfile(name: string, skillProfile?: string): void {
private addProfile(name: string, skillProfile?: string, cueProfile?: string): void {
const dir = path.join(CLAUDE_PARALLEL_DIR, name);
const existed = fs.existsSync(dir);
if (!existed) {
Expand All @@ -98,6 +113,10 @@ export default class ClaudeParallel extends Command {
if (skillProfile) {
writeSkillProfile(name, skillProfile);
}
const resolvedCueProfile = cueProfile ?? readCueProfile(name) ?? skillProfile ?? DEFAULT_CUE_PROFILE;
if (cueProfile || !readCueProfile(name)) {
writeCueProfile(name, resolvedCueProfile);
}

if (this.jsonMode) {
writeJsonEnvelope(jsonSuccess({
Expand All @@ -106,6 +125,7 @@ export default class ClaudeParallel extends Command {
dir,
created: !existed,
skillProfile: skillProfile ?? readSkillProfile(name) ?? "base",
cueProfile: resolvedCueProfile,
}));
return;
}
Expand All @@ -115,11 +135,13 @@ export default class ClaudeParallel extends Command {
if (skillProfile) {
this.log(` Skill profile: ${skillProfile}`);
}
this.log(` Cue profile: ${resolvedCueProfile}`);
return;
}
this.log(`Created profile: ${name}`);
this.log(` Config dir: ${dir}`);
this.log(` Skill profile: ${skillProfile ?? "base"}`);
this.log(` Cue profile: ${resolvedCueProfile}`);
this.log(` Run: CLAUDE_CONFIG_DIR=${dir} claude`);
this.log(`\nTo install shell aliases: agent-auth parallel --install`);
}
Expand Down Expand Up @@ -148,6 +170,7 @@ export default class ClaudeParallel extends Command {
name: p,
configDir: path.join(CLAUDE_PARALLEL_DIR, p),
skillProfile: readSkillProfile(p) ?? "base",
cueProfile: readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE,
}));

if (this.jsonMode) {
Expand All @@ -165,7 +188,7 @@ export default class ClaudeParallel extends Command {
}
this.log("Claude Code parallel profiles:\n");
for (const p of entries) {
this.log(` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile}`);
this.log(` • ${p.name} → ${p.configDir} skillProfile=${p.skillProfile} cueProfile=${p.cueProfile}`);
}
this.log(`\nRun any profile: claude-<name> (after installing aliases)`);
}
Expand All @@ -175,18 +198,21 @@ export default class ClaudeParallel extends Command {
if (!profiles.length) return "";
const lines = [
"# Claude Code parallel accounts (managed by agent-auth)",
"__authmux_claude_account() {",
" local name=\"$1\"",
` local profile="\${2:-${DEFAULT_CUE_PROFILE}}"`,
" shift 2",
" local dir=\"$HOME/.claude-accounts/$name\"",
" command authmux skills activate \"$profile\" --agent claude --target \"$dir/skills\" >/dev/null 2>&1 || true",
" if command -v cue >/dev/null 2>&1 && [ -z \"${AUTHMUX_SKIP_CUE_LAUNCH:-}\" ]; then",
" CLAUDE_CONFIG_DIR=\"$dir\" cue launch claude --cue-profile \"$profile\" \"$@\"",
" else",
" CLAUDE_CONFIG_DIR=\"$dir\" command claude \"$@\"",
" fi",
"}",
...profiles.map((p) => {
const dir = path.join(CLAUDE_PARALLEL_DIR, p);
const profile = readSkillProfile(p) ?? "base";
const activate = [
"command authmux skills activate",
shellQuote(profile),
"--agent claude",
"--target",
shellQuote(path.join(dir, "skills")),
">/dev/null 2>&1 || true",
].join(" ");
return `alias claude-${p}="${activate}; CLAUDE_CONFIG_DIR=${shellQuote(dir)} command claude"`;
const profile = readCueProfile(p) ?? readSkillProfile(p) ?? DEFAULT_CUE_PROFILE;
return `alias claude-${p}="__authmux_claude_account ${shellQuote(p)} ${shellQuote(profile)}"`;
}),
];
return lines.join("\n");
Expand Down
16 changes: 15 additions & 1 deletion src/lib/config/login-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,21 @@ export function renderLoginHookBlock(): string {
" [[ -w \"$__tty_target\" ]] || __tty_target=/dev/stdout",
" printf '\\033[>4m\\033[<u\\033[?2026l\\033[?1004l\\033[?1l\\033[?2004l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033[?1015l\\033[?1049l\\033[0m\\033[?25h\\033[H\\033>' >\"$__tty_target\" 2>/dev/null || true",
"}",
"__authmux_ensure_session_key() {",
" if [[ -n \"${CODEX_AUTH_SESSION_KEY:-}\" ]]; then",
" return 0",
" fi",
" local __authmux_tty=\"${TTY:-}\"",
" if [[ -z \"$__authmux_tty\" ]] && command -v tty >/dev/null 2>&1; then",
" __authmux_tty=\"$(tty 2>/dev/null || true)\"",
" fi",
" if [[ -z \"$__authmux_tty\" || \"$__authmux_tty\" == \"not a tty\" ]]; then",
" __authmux_tty=\"ppid:${PPID:-$$}\"",
" fi",
" export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\"",
"}",
"codex() {",
" __authmux_ensure_session_key",
" if command -v authmux >/dev/null 2>&1; then",
" command authmux restore-session >/dev/null 2>&1 || true",
" command authmux skills activate-current --agent codex >/dev/null 2>&1 || true",
Expand Down Expand Up @@ -79,7 +93,7 @@ export async function installLoginHook(rcPath = resolveDefaultShellRcPath()): Pr

if (existing.includes(LOGIN_HOOK_MARK_START) && existing.includes(LOGIN_HOOK_MARK_END)) {
const refreshed = normalizeRcContents(
existing.replace(hookBlockRegex(), `\n${renderLoginHookBlock()}\n`),
existing.replace(hookBlockRegex(), () => `\n${renderLoginHookBlock()}\n`),
);
if (refreshed === normalizeRcContents(existing)) {
return "already-installed";
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 @@ -250,3 +250,52 @@ for (const tc of CASES) {
});
});
}

test("parallel aliases pass explicit cue profile per Claude account", async () => {
await withSandbox(async (env) => {
const addDefault = runCli(
["parallel", "--add", "account1", "--json"],
env,
);
assert.equal(addDefault.status, 0, addDefault.stderr);

const parsedDefault = JSON.parse(addDefault.stdout.trim()) as {
ok: true;
data: { profile: string; skillProfile: string; cueProfile: string };
};
assert.equal(parsedDefault.data.profile, "account1");
assert.equal(parsedDefault.data.skillProfile, "base");
assert.equal(parsedDefault.data.cueProfile, "core");

const add = runCli(
["parallel", "--add", "account2", "--skill-profile", "frontend", "--json"],
env,
);
assert.equal(add.status, 0, add.stderr);

const parsedAdd = JSON.parse(add.stdout.trim()) as {
ok: true;
data: { profile: string; skillProfile: string; cueProfile: string };
};
assert.equal(parsedAdd.data.profile, "account2");
assert.equal(parsedAdd.data.skillProfile, "frontend");
assert.equal(parsedAdd.data.cueProfile, "frontend");

const aliases = runCli(["parallel", "--aliases", "--json"], env);
assert.equal(aliases.status, 0, aliases.stderr);

const parsedAliases = JSON.parse(aliases.stdout.trim()) as {
ok: true;
data: { aliases: string };
};
assert.match(parsedAliases.data.aliases, /__authmux_claude_account\(\)/);
assert.match(
parsedAliases.data.aliases,
/cue launch claude --cue-profile "\$profile"/,
);
assert.match(
parsedAliases.data.aliases,
/alias claude-account2="__authmux_claude_account 'account2' 'frontend'"/,
);
});
});
4 changes: 4 additions & 0 deletions src/tests/login-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ test("installLoginHook refreshes an existing legacy hook block", async (t) => {
assert.ok(contents.includes("command authmux restore-session"));
assert.ok(contents.includes("command authmux skills activate-current --agent codex"));
assert.ok(contents.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status"));
assert.ok(contents.includes("__authmux_ensure_session_key"));
assert.ok(contents.includes("export CODEX_AUTH_SESSION_KEY=\"terminal:${__authmux_tty}:$$\""));
assert.ok(!contents.includes("# legacy"));
const startCount = contents.split(LOGIN_HOOK_MARK_START).length - 1;
assert.equal(startCount, 1);
Expand Down Expand Up @@ -109,7 +111,9 @@ test("getLoginHookStatus reflects installed state", async (t) => {
test("renderLoginHookBlock includes terminal-mode restore guard", () => {
const hook = renderLoginHookBlock();
assert.ok(hook.includes("__codex_auth_restore_tty"));
assert.ok(hook.includes("__authmux_ensure_session_key"));
assert.ok(hook.includes("codex() {"));
assert.ok(hook.includes("__authmux_ensure_session_key"));
assert.ok(hook.includes("command authmux restore-session"));
assert.ok(hook.includes("command authmux skills activate-current --agent codex"));
assert.ok(hook.includes("CODEX_AUTH_FORCE_EXTERNAL_SYNC=1 command authmux status"));
Expand Down
Loading