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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,18 @@ authmux parallel --login <name> # Refresh Claude OAuth inside that profile
authmux parallel --login <name> --fresh --require-distinct # Reset stale Claude auth first and fail if it matches another profile
authmux parallel --remove <name> # Remove a profile
authmux parallel --list # List all profiles
authmux parallel --sessions # List per-session Claude config copies
authmux parallel --clean-sessions # Remove stale inactive session copies
authmux parallel --doctor # Check wrappers, profiles, duplicates, sessions
authmux parallel --aliases # Print aliases (without installing)
authmux parallel --install # Write aliases to shell rc file
```

### How it works

Each profile gets its own config directory at `~/.claude-accounts/<name>`. Shell aliases/functions launch Claude from a per-session copy of that profile, so already-open sessions keep their original credentials even after a later `/login`. If a session refreshes login credentials, Authmux syncs the changed Claude auth files back to `~/.claude-accounts/<name>` so future `claude-<name>` launches use the new login. `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.
Each profile gets its own config directory at `~/.claude-accounts/<name>`. Shell aliases/functions launch Claude from a per-session copy of that profile, so already-open sessions keep their original credentials even after a later `/login`. If a session refreshes login credentials, Authmux syncs the changed Claude auth files back to `~/.claude-accounts/<name>` so future `claude-<name>` launches use the new login. That sync is serialized with a per-profile lock and writes auth files atomically with `0600` permissions. If another session already changed the canonical profile, Authmux keeps the session copy for recovery instead of overwriting newer credentials.

`authmux parallel --sessions` shows live and stale session copies under `~/.claude-accounts-sessions`, and `authmux parallel --clean-sessions` removes inactive stale copies. `authmux parallel --doctor` checks that wrappers use the session-isolated runner, profiles have credentials, duplicates are visible, and stale sessions can be cleaned. `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 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.

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,24 @@
# agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09 (minimal / T1)

Branch: `agent/codex/harden-claude-parallel-sessions-2026-06-29-23-09`

Harden Claude parallel session isolation: locked atomic credential sync, stale session inventory/cleanup, conflict warnings, doctor checks, and live wrapper refresh after primary builds.

## Handoff

- Handoff: change=`agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09`; branch=`agent/codex/harden-claude-parallel-sessions-2026-06-29-23-09`; scope=`src/commands/parallel.ts src/tests/json-parity.test.ts README.md package.json scripts/postbuild-parallel-install.cjs`; action=`implement all requested Claude parallel hardening and finish via PR`.
- Copy prompt: Continue `agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09` on branch `agent/codex/harden-claude-parallel-sessions-2026-06-29-23-09`. Work inside the existing sandbox, review `openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/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/harden-claude-parallel-sessions-2026-06-29-23-09 --base main --via-pr --wait-for-merge --cleanup`.

## Verification

- `npm run build` passed.
- `node dist/tests/json-parity.test.js` passed: 19 tests.
- `npm test` passed: 204 tests.
- `node dist/index.js parallel --help` shows `--sessions`, `--clean-sessions`, and `--doctor`.
- `node dist/index.js parallel --doctor --shell fish --json` reports existing Fish wrappers use `authmux parallel --run`.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/codex/harden-claude-parallel-sessions-2026-06-29-23-09 --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`).
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"postbuild": "node scripts/postbuild-parallel-install.cjs",
"postinstall": "node scripts/postinstall-login-hook.cjs",
"prepublishOnly": "npm run build",
"test": "npm run build && node scripts/run-tests.cjs"
Expand All @@ -20,6 +21,7 @@
},
"files": [
"dist",
"scripts/postbuild-parallel-install.cjs",
"scripts/postinstall-login-hook.cjs",
"README.md",
"LICENSE"
Expand Down
97 changes: 33 additions & 64 deletions scripts/claude-parallel-setup.sh
Original file line number Diff line number Diff line change
@@ -1,73 +1,42 @@
#!/usr/bin/env bash
# claude-parallel-setup.sh — Set up parallel Claude Code accounts
# Usage: ./claude-parallel-setup.sh [account_names...]
# Example: ./claude-parallel-setup.sh work personal client
# claude-parallel-setup.sh — Set up parallel Claude Code accounts.
# Usage: ./scripts/claude-parallel-setup.sh [account_names...]

set -euo pipefail

ACCOUNTS_DIR="$HOME/.claude-accounts"
MARKER_START="# >>> codex-auth claude-parallel >>>"
MARKER_END="# <<< codex-auth claude-parallel <<<"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

detect_rc() {
if [[ "${SHELL:-}" == *zsh ]]; then echo "$HOME/.zshrc"
else echo "$HOME/.bashrc"; fi
detect_shell() {
case "${SHELL:-}" in
*fish*) printf 'fish' ;;
*zsh*) printf 'zsh' ;;
*) printf 'bash' ;;
esac
}

create_profiles() {
local names=("$@")
if [[ ${#names[@]} -eq 0 ]]; then
names=(account1 account2)
echo "No names given, using defaults: ${names[*]}"
fi
for name in "${names[@]}"; do
local dir="$ACCOUNTS_DIR/$name"
mkdir -p "$dir"
echo "Created: $dir"
done
}

generate_aliases() {
local profiles=()
for d in "$ACCOUNTS_DIR"/*/; do
[[ -d "$d" ]] && profiles+=("$(basename "$d")")
done
if [[ ${#profiles[@]} -eq 0 ]]; then
echo "No profiles in $ACCOUNTS_DIR" >&2; return 1
fi
echo "$MARKER_START"
echo "# Claude Code parallel accounts (managed by codex-auth)"
for p in "${profiles[@]}"; do
echo "alias claude-${p}=\"CLAUDE_CONFIG_DIR=$ACCOUNTS_DIR/$p command claude\""
done
local usage
usage=$(printf ", claude-%s" "${profiles[@]}")
echo "alias claude=\"echo 'Use: ${usage:2}'\""
echo "$MARKER_END"
}

install_aliases() {
local rc
rc=$(detect_rc)
local block
block=$(generate_aliases) || return 1

if [[ -f "$rc" ]]; then
# Remove old block
sed -i "/$MARKER_START/,/$MARKER_END/d" "$rc"
fi

echo "" >> "$rc"
echo "$block" >> "$rc"
echo "Installed aliases in $rc"
echo "Run: source $rc"
}
authmux_cmd=()
if command -v authmux >/dev/null 2>&1; then
authmux_cmd=(authmux)
elif [[ -f "$repo_root/dist/index.js" ]]; then
authmux_cmd=(node "$repo_root/dist/index.js")
else
echo "authmux is not installed and $repo_root/dist/index.js is missing. Run: npm run build" >&2
exit 1
fi

names=("$@")
if [[ ${#names[@]} -eq 0 ]]; then
names=(account1 account2)
echo "No names given, using defaults: ${names[*]}"
fi

for name in "${names[@]}"; do
"${authmux_cmd[@]}" parallel --add "$name"
done

shell="$(detect_shell)"
"${authmux_cmd[@]}" parallel --install --shell "$shell"

# Main
create_profiles "$@"
install_aliases
echo ""
echo "Next steps:"
echo " 1. source $(detect_rc)"
echo " 2. Run claude-<name> in separate terminals to authenticate each account"
echo " 3. Each account runs independently with its own usage limits"
echo "Installed session-isolated Claude parallel wrappers for $shell."
echo "Run: authmux parallel --doctor --shell $shell"
117 changes: 117 additions & 0 deletions scripts/postbuild-parallel-install.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env node
"use strict";

const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");

function isTruthy(value) {
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
}

function findCommand(command) {
const extensions = process.platform === "win32"
? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
: [""];

for (const entry of (process.env.PATH || "").split(path.delimiter)) {
if (!entry) continue;
for (const extension of extensions) {
const candidate = path.join(entry, `${command}${extension}`);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
// Try the next PATH entry.
}
}
}
return null;
}

function detectShell() {
const shell = (process.env.SHELL || "").toLowerCase();
if (shell.includes("fish")) return "fish";
if (shell.includes("zsh")) return "zsh";
return "bash";
}

function listProfiles(home) {
const accountsDir = path.join(home, ".claude-accounts");
if (!fs.existsSync(accountsDir)) return [];
return fs.readdirSync(accountsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => entry.name);
}

function backupWrappers(shell, home) {
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.(\d{3})Z$/, "$1Z");
if (shell === "fish") {
const functionsDir = path.join(home, ".config", "fish", "functions");
if (!fs.existsSync(functionsDir)) return null;
const files = fs.readdirSync(functionsDir)
.filter((name) => /^claude-.+\.fish$/.test(name));
if (!files.length) return null;
const backupDir = path.join(home, ".config", "fish", `functions.authmux-build-backup-${stamp}`);
fs.mkdirSync(backupDir, { recursive: true });
for (const file of files) {
fs.copyFileSync(path.join(functionsDir, file), path.join(backupDir, file));
}
return backupDir;
}

const rc = path.join(home, shell === "zsh" ? ".zshrc" : ".bashrc");
if (!fs.existsSync(rc)) return null;
const backup = `${rc}.authmux-build-backup-${stamp}`;
fs.copyFileSync(rc, backup);
return backup;
}

function main() {
if (isTruthy(process.env.AUTHMUX_SKIP_POSTBUILD_PARALLEL_INSTALL)) return;

const repoRoot = path.resolve(__dirname, "..");
const distEntry = path.join(repoRoot, "dist", "index.js");
if (!fs.existsSync(distEntry)) return;

const liveAuthmux = findCommand("authmux");
const liveTarget = liveAuthmux ? fs.realpathSync(liveAuthmux) : "";
const force = isTruthy(process.env.AUTHMUX_REFRESH_PARALLEL_WRAPPERS);
if (!force && liveTarget !== fs.realpathSync(distEntry)) return;

const home = os.homedir();
const profiles = listProfiles(home);
if (!profiles.length) return;

const shell = detectShell();
const backup = backupWrappers(shell, home);
const result = spawnSync(process.execPath, [
distEntry,
"parallel",
"--install",
"--shell",
shell,
"--json",
], {
encoding: "utf8",
env: {
...process.env,
AUTHMUX_SKIP_POSTBUILD_PARALLEL_INSTALL: "1",
},
});

if (result.status !== 0 || result.error) {
const reason = result.error ? result.error.message : result.stderr.trim();
process.stderr.write(`[authmux postbuild] Skipped Claude wrapper refresh: ${reason}\n`);
return;
}

process.stderr.write(
`[authmux postbuild] Refreshed Claude parallel ${shell} wrappers` +
(backup ? `; backup=${backup}` : "") +
".\n",
);
}

main();
Loading
Loading