diff --git a/README.md b/README.md index 62404b9..a8dfd82 100644 --- a/README.md +++ b/README.md @@ -244,13 +244,18 @@ authmux parallel --login # Refresh Claude OAuth inside that profile authmux parallel --login --fresh --require-distinct # Reset stale Claude auth first and fail if it matches another profile authmux parallel --remove # 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/`. 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/` so future `claude-` 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 --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/`. 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/` so future `claude-` 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 --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 --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. diff --git a/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/.openspec.yaml b/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/notes.md b/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/notes.md new file mode 100644 index 0000000..8b959f7 --- /dev/null +++ b/openspec/changes/agent-codex-harden-claude-parallel-sessions-2026-06-29-23-09/notes.md @@ -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`). diff --git a/package.json b/package.json index 2a538e5..bc07d55 100644 --- a/package.json +++ b/package.json @@ -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" @@ -20,6 +21,7 @@ }, "files": [ "dist", + "scripts/postbuild-parallel-install.cjs", "scripts/postinstall-login-hook.cjs", "README.md", "LICENSE" diff --git a/scripts/claude-parallel-setup.sh b/scripts/claude-parallel-setup.sh index 1fdc503..8743c7a 100755 --- a/scripts/claude-parallel-setup.sh +++ b/scripts/claude-parallel-setup.sh @@ -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- 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" diff --git a/scripts/postbuild-parallel-install.cjs b/scripts/postbuild-parallel-install.cjs new file mode 100644 index 0000000..6829da6 --- /dev/null +++ b/scripts/postbuild-parallel-install.cjs @@ -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(); diff --git a/src/commands/parallel.ts b/src/commands/parallel.ts index a2513fc..5119ea1 100644 --- a/src/commands/parallel.ts +++ b/src/commands/parallel.ts @@ -17,10 +17,14 @@ import { const CLAUDE_PARALLEL_DIR = path.join(os.homedir(), ".claude-accounts"); const CLAUDE_PARALLEL_BACKUP_DIR = path.join(os.homedir(), ".claude-accounts-backups"); const CLAUDE_PARALLEL_SESSION_DIR = path.join(os.homedir(), ".claude-accounts-sessions"); +const CLAUDE_PARALLEL_LOCK_DIR = path.join(os.homedir(), ".claude-accounts-locks"); const SKILL_PROFILE_FILE = ".authmux-skill-profile"; const CUE_PROFILE_FILE = ".authmux-cue-profile"; const DEFAULT_CUE_PROFILE = "core"; const SESSION_SYNC_INTERVAL_MS = 1_000; +const DEFAULT_STALE_SESSION_MS = 24 * 60 * 60 * 1_000; +const DEFAULT_FINAL_LOCK_WAIT_MS = 5_000; +const LOCK_STALE_MS = 30_000; type ShellName = "bash" | "zsh" | "fish"; type LoginProfileOptions = { fresh: boolean; @@ -42,6 +46,30 @@ type LaunchCommand = { args: string[]; }; +type SyncStatus = "synced" | "unchanged" | "conflict" | "locked"; + +type SyncResult = { + status: SyncStatus; + reason?: string; +}; + +type ParallelSessionEntry = { + profile: string; + dir: string; + createdAt: string; + ageMs: number; + pid?: number; + active: boolean; + credentialsPresent: boolean; + claudeAccountUuid?: string; +}; + +type ParallelDoctorCheck = { + name: string; + status: "ok" | "warn" | "error"; + message: string; +}; + interface ParallelProfileEntry { name: string; configDir: string; @@ -172,13 +200,84 @@ function restoreProfileAuthBackup(backup: AuthBackup | undefined): void { } } +function numericEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +function sleepSync(ms: number): void { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function lockNameForProfile(name: string): string { + return `${Buffer.from(name).toString("base64url")}.sync.lock`; +} + +function lockDirForProfile(name: string): string { + return path.join(CLAUDE_PARALLEL_LOCK_DIR, lockNameForProfile(name)); +} + +function acquireProfileSyncLock(name: string, waitMs: number): string | undefined { + const lockDir = lockDirForProfile(name); + const deadline = Date.now() + waitMs; + + fs.mkdirSync(CLAUDE_PARALLEL_LOCK_DIR, { recursive: true }); + while (true) { + try { + fs.mkdirSync(lockDir); + fs.writeFileSync( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ + profile: name, + pid: process.pid, + createdAt: new Date().toISOString(), + })}\n`, + ); + return lockDir; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EEXIST") throw error; + + const stat = fs.statSync(lockDir, { throwIfNoEntry: false }); + if (!stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS) { + fs.rmSync(lockDir, { recursive: true, force: true }); + continue; + } + + if (Date.now() >= deadline) { + return undefined; + } + sleepSync(Math.min(100, Math.max(0, deadline - Date.now()))); + } + } +} + +function releaseProfileSyncLock(lockDir: string | undefined): void { + if (!lockDir) return; + fs.rmSync(lockDir, { recursive: true, force: true }); +} + +function atomicCopyAuthFile(source: string, destination: string): void { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const temp = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${Date.now()}.tmp`, + ); + fs.copyFileSync(source, temp); + fs.chmodSync(temp, 0o600); + fs.renameSync(temp, destination); +} + function copyAuthFiles(fromDir: string, toDir: string): void { fs.mkdirSync(toDir, { recursive: true }); for (const file of [".credentials.json", ".claude.json"]) { const source = path.join(fromDir, file); const destination = path.join(toDir, file); if (fs.existsSync(source)) { - fs.copyFileSync(source, destination); + atomicCopyAuthFile(source, destination); } else { fs.rmSync(destination, { force: true }); } @@ -260,6 +359,20 @@ function readAuthIdentity(dir: string): AuthIdentity { }; } +function hasAuthIdentity(identity: AuthIdentity): boolean { + return Boolean(identity.claudeAccountUuid || identity.credentialsHash); +} + +function authIdentitiesEqual(left: AuthIdentity, right: AuthIdentity): boolean { + if (left.claudeAccountUuid && right.claudeAccountUuid) { + return left.claudeAccountUuid === right.claudeAccountUuid; + } + if (left.credentialsHash && right.credentialsHash) { + return left.credentialsHash === right.credentialsHash; + } + return false; +} + function authFileHashes(dir: string): Map { return new Map( [".credentials.json", ".claude.json"].map((file) => [ @@ -279,34 +392,112 @@ function authHashesChanged( return false; } -function shouldSyncSessionAuth(profileDir: string, sessionDir: string, initialIdentity: AuthIdentity): boolean { +function isProcessActive(pid: number | undefined): boolean { + if (!pid || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function sessionPidFromDirName(name: string): number | undefined { + const match = name.match(/-(\d+)$/); + if (!match) return undefined; + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function listSessionEntries(): ParallelSessionEntry[] { + if (!fs.existsSync(CLAUDE_PARALLEL_SESSION_DIR)) return []; + const now = Date.now(); + const entries: ParallelSessionEntry[] = []; + + for (const profileEntry of fs.readdirSync(CLAUDE_PARALLEL_SESSION_DIR, { withFileTypes: true })) { + if (!profileEntry.isDirectory()) continue; + const profileDir = path.join(CLAUDE_PARALLEL_SESSION_DIR, profileEntry.name); + for (const sessionEntry of fs.readdirSync(profileDir, { withFileTypes: true })) { + if (!sessionEntry.isDirectory()) continue; + const sessionDir = path.join(profileDir, sessionEntry.name); + const stat = fs.statSync(sessionDir, { throwIfNoEntry: false }); + if (!stat) continue; + const credentialsStat = statFileIfPresent(credentialsPathForDir(sessionDir)); + const claudeAccount = readClaudeAccountUuidFromDir(sessionDir, credentialsStat?.mtimeMs); + const pid = sessionPidFromDirName(sessionEntry.name); + entries.push({ + profile: profileEntry.name, + dir: sessionDir, + createdAt: new Date(stat.mtimeMs).toISOString(), + ageMs: Math.max(0, now - stat.mtimeMs), + pid, + active: isProcessActive(pid), + credentialsPresent: Boolean(credentialsStat), + claudeAccountUuid: claudeAccount.uuid, + }); + } + } + + return entries.sort((a, b) => a.profile.localeCompare(b.profile) || a.createdAt.localeCompare(b.createdAt)); +} + +function cleanupStaleSessions(maxAgeMs = numericEnv("AUTHMUX_PARALLEL_STALE_SESSION_MS", DEFAULT_STALE_SESSION_MS)): { + removed: ParallelSessionEntry[]; + kept: ParallelSessionEntry[]; +} { + const removed: ParallelSessionEntry[] = []; + const kept: ParallelSessionEntry[] = []; + + for (const entry of listSessionEntries()) { + if (!entry.active && entry.ageMs >= maxAgeMs) { + fs.rmSync(entry.dir, { recursive: true, force: true }); + removed.push(entry); + } else { + kept.push(entry); + } + } + + return { removed, kept }; +} + +function shouldSyncSessionAuth(profileDir: string, sessionDir: string, initialIdentity: AuthIdentity): SyncResult { const sessionIdentity = readAuthIdentity(sessionDir); const profileIdentity = readAuthIdentity(profileDir); if ( - sessionIdentity.claudeAccountUuid && - initialIdentity.claudeAccountUuid && - sessionIdentity.claudeAccountUuid !== initialIdentity.claudeAccountUuid + hasAuthIdentity(profileIdentity) && + !authIdentitiesEqual(profileIdentity, initialIdentity) && + !authIdentitiesEqual(profileIdentity, sessionIdentity) ) { - return true; - } - - if (!profileIdentity.claudeAccountUuid || !initialIdentity.claudeAccountUuid) { - return true; + return { + status: "conflict", + reason: "canonical profile changed in another session", + }; } - return ( - profileIdentity.claudeAccountUuid === initialIdentity.claudeAccountUuid || - profileIdentity.claudeAccountUuid === sessionIdentity.claudeAccountUuid - ); + return { status: "synced" }; } -function syncSessionAuthToProfile(profileDir: string, sessionDir: string, initialIdentity: AuthIdentity): boolean { - if (!shouldSyncSessionAuth(profileDir, sessionDir, initialIdentity)) { - return false; +function syncSessionAuthToProfile( + name: string, + profileDir: string, + sessionDir: string, + initialIdentity: AuthIdentity, + options: { waitMs: number }, +): SyncResult { + const lockDir = acquireProfileSyncLock(name, options.waitMs); + if (!lockDir) { + return { status: "locked", reason: "sync lock is held by another process" }; + } + + try { + const decision = shouldSyncSessionAuth(profileDir, sessionDir, initialIdentity); + if (decision.status !== "synced") return decision; + copyAuthFiles(sessionDir, profileDir); + return { status: "synced" }; + } finally { + releaseProfileSyncLock(lockDir); } - copyAuthFiles(sessionDir, profileDir); - return true; } function duplicateOwner( @@ -359,6 +550,9 @@ export default class ClaudeParallel extends Command { remove: Flags.string({ description: "Remove a profile" }), aliases: Flags.boolean({ description: "Print shell aliases for all profiles" }), install: Flags.boolean({ description: "Install aliases into shell rc file" }), + sessions: Flags.boolean({ description: "List Claude parallel session copies" }), + "clean-sessions": Flags.boolean({ description: "Remove stale inactive Claude parallel session copies" }), + doctor: Flags.boolean({ description: "Check Claude parallel wrapper and profile health" }), 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- aliases" }), @@ -380,6 +574,9 @@ export default class ClaudeParallel extends Command { "agent-auth parallel --login work", "agent-auth parallel --login work --fresh --require-distinct", "agent-auth parallel --run work -- --model opus", + "agent-auth parallel --sessions", + "agent-auth parallel --clean-sessions", + "agent-auth parallel --doctor", "agent-auth parallel --list", "agent-auth parallel --aliases", "agent-auth parallel --install", @@ -410,6 +607,12 @@ export default class ClaudeParallel extends Command { this.installAliases(shell); } else if (flags.aliases) { this.printAliases(shell); + } else if (flags.sessions) { + this.listSessions(); + } else if (flags["clean-sessions"]) { + this.cleanSessions(); + } else if (flags.doctor) { + this.doctor(shell); } else { this.listProfiles(); } @@ -601,6 +804,178 @@ export default class ClaudeParallel extends Command { } } + private listSessions(): void { + const sessions = listSessionEntries(); + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "sessions" as const, + sessions, + })); + return; + } + + if (!sessions.length) { + this.log("No Claude parallel session copies found."); + return; + } + + this.log("Claude parallel session copies:\n"); + for (const session of sessions) { + const pidSuffix = session.pid ? ` pid=${session.pid}` : ""; + const uuidSuffix = session.claudeAccountUuid ? ` claudeAccount=${session.claudeAccountUuid}` : ""; + this.log( + ` ${session.profile} ${session.active ? "active" : "inactive"}${pidSuffix}` + + ` ageMs=${session.ageMs} credentials=${session.credentialsPresent ? "present" : "missing"}` + + `${uuidSuffix} dir=${session.dir}`, + ); + } + } + + private cleanSessions(): void { + const maxAgeMs = numericEnv("AUTHMUX_PARALLEL_STALE_SESSION_MS", DEFAULT_STALE_SESSION_MS); + const result = cleanupStaleSessions(maxAgeMs); + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "clean-sessions" as const, + maxAgeMs, + removed: result.removed, + kept: result.kept, + })); + return; + } + + this.log(`Removed ${result.removed.length} stale Claude parallel session copy/copies.`); + if (result.kept.length) { + this.log(`Kept ${result.kept.length} active or fresh session copy/copies.`); + } + } + + private doctor(shell: ShellName): void { + const checks = this.buildDoctorChecks(shell); + if (this.jsonMode) { + writeJsonEnvelope(jsonSuccess({ + action: "doctor" as const, + shell, + checks, + })); + return; + } + + for (const check of checks) { + this.log(`${check.status.toUpperCase()} ${check.name}: ${check.message}`); + } + } + + private buildDoctorChecks(shell: ShellName): ParallelDoctorCheck[] { + const profiles = getProfiles(); + const entries = buildProfileEntries(profiles); + const sessions = listSessionEntries(); + const staleSessions = sessions.filter((session) => !session.active && session.ageMs >= DEFAULT_STALE_SESSION_MS); + const checks: ParallelDoctorCheck[] = [ + { + name: "parallel-run", + status: "ok", + message: "`authmux parallel --run` is available.", + }, + { + name: "profiles", + status: profiles.length ? "ok" : "warn", + message: profiles.length + ? `${profiles.length} Claude parallel profile(s) configured.` + : "No Claude parallel profiles configured.", + }, + { + name: "claude-cli", + status: commandExists("claude") ? "ok" : "warn", + message: commandExists("claude") + ? "`claude` is available in PATH." + : "`claude` was not found in PATH; profile launch will fail until Claude Code is installed.", + }, + { + name: "cue-cli", + status: commandExists("cue") ? "ok" : "warn", + message: commandExists("cue") + ? "`cue` is available for profile launches." + : "`cue` was not found; wrappers will fall back to direct `claude` launch.", + }, + { + name: "sessions", + status: staleSessions.length ? "warn" : "ok", + message: staleSessions.length + ? `${staleSessions.length} stale inactive session copy/copies can be removed with \`authmux parallel --clean-sessions\`.` + : `${sessions.length} session copy/copies found; none are stale.`, + }, + ]; + + for (const entry of entries) { + checks.push({ + name: `credentials:${entry.name}`, + status: entry.credentialsPresent ? "ok" : "warn", + message: entry.credentialsPresent + ? `Credentials present for "${entry.name}".` + : `Credentials missing for "${entry.name}"; run \`claude-${entry.name}\` or \`authmux parallel --login ${entry.name}\`.`, + }); + + if (entry.credentialsDuplicateOf || entry.claudeAccountDuplicateOf) { + const other = entry.credentialsDuplicateOf ?? entry.claudeAccountDuplicateOf; + checks.push({ + name: `duplicate:${entry.name}`, + status: "warn", + message: `"${entry.name}" shares Claude auth with "${other}".`, + }); + } + } + + checks.push(...this.wrapperDoctorChecks(shell, profiles)); + return checks; + } + + private wrapperDoctorChecks(shell: ShellName, profiles: string[]): ParallelDoctorCheck[] { + if (!profiles.length) return []; + + if (shell === "fish") { + return profiles.map((profile): ParallelDoctorCheck => { + const file = path.join(fishFunctionsDir(), `claude-${profile}.fish`); + if (!fs.existsSync(file)) { + return { + name: `wrapper:${profile}`, + status: "warn", + message: `Fish function ${file} is not installed.`, + }; + } + + const body = fs.readFileSync(file, "utf8"); + const ok = body.includes("authmux parallel --run"); + return { + name: `wrapper:${profile}`, + status: ok ? "ok" : "error", + message: ok + ? `Fish function for "${profile}" uses session-isolated runner.` + : `Fish function for "${profile}" is stale; run \`authmux parallel --install --shell fish\`.`, + }; + }); + } + + const rc = shellRcPath(shell); + if (!fs.existsSync(rc)) { + return [{ + name: "wrapper", + status: "warn", + message: `${rc} does not exist.`, + }]; + } + + const body = fs.readFileSync(rc, "utf8"); + const ok = body.includes("authmux parallel --run"); + return [{ + name: "wrapper", + status: ok ? "ok" : "error", + message: ok + ? `${rc} uses session-isolated runner.` + : `${rc} has stale Claude parallel aliases; run \`authmux parallel --install --shell ${shell}\`.`, + }]; + } + private resolveLaunchCommand(cueProfile: string, args: string[]): LaunchCommand { if (commandExists("cue") && !process.env.AUTHMUX_SKIP_CUE_LAUNCH) { if (cueProfile === "pick") { @@ -634,18 +1009,39 @@ export default class ClaudeParallel extends Command { this.error(`Profile "${name}" not found.`); } + cleanupStaleSessions(); const sessionDir = createProfileSessionDir(name); copyProfileToSession(profileDir, sessionDir); const initialIdentity = readAuthIdentity(sessionDir); let lastHashes = authFileHashes(sessionDir); + let preserveSessionDir = false; + let warnedSyncProblem = false; const cueProfile = options.cueProfile ?? readCueProfile(name) ?? DEFAULT_CUE_PROFILE; const launch = this.resolveLaunchCommand(cueProfile, options.args); - const syncIfChanged = (): void => { + const warnSyncProblem = (result: SyncResult): void => { + if (result.status !== "conflict" && result.status !== "locked") return; + preserveSessionDir = true; + if (warnedSyncProblem) return; + warnedSyncProblem = true; + this.warn( + `Claude auth changes for "${name}" were not copied back because ${result.reason ?? "sync failed"}. ` + + `Kept this session copy at ${sessionDir} for recovery.`, + ); + }; + + const syncIfChanged = (waitMs: number): SyncResult => { const currentHashes = authFileHashes(sessionDir); - if (!authHashesChanged(lastHashes, currentHashes)) return; - syncSessionAuthToProfile(profileDir, sessionDir, initialIdentity); - lastHashes = currentHashes; + if (!authHashesChanged(lastHashes, currentHashes)) { + return { status: "unchanged" }; + } + const result = syncSessionAuthToProfile(name, profileDir, sessionDir, initialIdentity, { waitMs }); + if (result.status === "synced") { + lastHashes = currentHashes; + return result; + } + warnSyncProblem(result); + return result; }; let interval: NodeJS.Timeout | undefined; @@ -658,13 +1054,13 @@ export default class ClaudeParallel extends Command { }, }); - interval = setInterval(syncIfChanged, SESSION_SYNC_INTERVAL_MS); + interval = setInterval(() => syncIfChanged(0), SESSION_SYNC_INTERVAL_MS); const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { child.on("error", reject); child.on("close", (code, signal) => resolve({ code, signal })); }); - syncIfChanged(); + syncIfChanged(numericEnv("AUTHMUX_PARALLEL_SYNC_LOCK_WAIT_MS", DEFAULT_FINAL_LOCK_WAIT_MS)); if (result.signal) { this.error(`${launch.command} was terminated by signal ${result.signal}.`); @@ -680,7 +1076,9 @@ export default class ClaudeParallel extends Command { throw error; } finally { if (interval) clearInterval(interval); - fs.rmSync(sessionDir, { recursive: true, force: true }); + if (!preserveSessionDir) { + fs.rmSync(sessionDir, { recursive: true, force: true }); + } } } diff --git a/src/tests/json-parity.test.ts b/src/tests/json-parity.test.ts index 0d0d4b8..24ed567 100644 --- a/src/tests/json-parity.test.ts +++ b/src/tests/json-parity.test.ts @@ -189,6 +189,10 @@ function runCli(argv: string[], env: NodeJS.ProcessEnv): { stdout: string; stder }; } +function lockDirForProfile(home: string, profile: string): string { + return path.join(home, ".claude-accounts-locks", `${Buffer.from(profile).toString("base64url")}.sync.lock`); +} + test("parallel --list flags duplicate Claude credentials", async () => { await withSandbox(async (env) => { for (const profile of ["account1", "account2"]) { @@ -417,6 +421,191 @@ test("parallel --run isolates live Claude sessions and syncs changed login crede ); assert.equal(await fsp.readFile(path.join(accountDir, ".credentials.json"), "utf8"), newCredentials); assert.equal(await fsp.readFile(path.join(accountDir, ".claude.json"), "utf8"), newClaudeState); + if (process.platform !== "win32") { + const credentialsStat = await fsp.stat(path.join(accountDir, ".credentials.json")); + const claudeStateStat = await fsp.stat(path.join(accountDir, ".claude.json")); + assert.equal(credentialsStat.mode & 0o777, 0o600); + assert.equal(claudeStateStat.mode & 0o777, 0o600); + } + }); +}); + +test("parallel --run preserves session auth when sync lock is busy", async () => { + await withSandbox(async (env) => { + const added = runCli(["parallel", "--add", "account2", "--json"], env); + assert.equal(added.status, 0, added.stderr); + + const accountsDir = path.join(env.HOME as string, ".claude-accounts"); + const accountDir = path.join(accountsDir, "account2"); + const oldCredentials = JSON.stringify({ claudeAiOauth: { accessToken: "old-access-token" } }); + const oldClaudeState = JSON.stringify({ oauthAccount: { accountUuid: "old-claude-account" } }); + const newCredentials = JSON.stringify({ claudeAiOauth: { accessToken: "new-access-token" } }); + const newClaudeState = JSON.stringify({ oauthAccount: { accountUuid: "new-claude-account" } }); + await fsp.writeFile(path.join(accountDir, ".credentials.json"), oldCredentials); + await fsp.writeFile(path.join(accountDir, ".claude.json"), oldClaudeState); + + const busyLock = lockDirForProfile(env.HOME as string, "account2"); + await fsp.mkdir(busyLock, { recursive: true }); + + const fakeBin = path.join(env.HOME as string, "bin"); + await fsp.mkdir(fakeBin, { recursive: true }); + const seenConfigDir = path.join(env.HOME as string, "seen-config-dir.txt"); + const fakeClaude = path.join(fakeBin, "claude"); + await fsp.writeFile( + fakeClaude, + [ + "#!/bin/sh", + "set -eu", + "printf '%s' \"$CLAUDE_CONFIG_DIR\" > \"$SEEN_CONFIG_DIR\"", + `printf '%s' '${newCredentials}' > "$CLAUDE_CONFIG_DIR/.credentials.json"`, + `printf '%s' '${newClaudeState}' > "$CLAUDE_CONFIG_DIR/.claude.json"`, + ].join("\n") + "\n", + ); + await fsp.chmod(fakeClaude, 0o755); + + const run = runCli( + ["parallel", "--run", "account2", "--", "--probe-arg"], + { + ...env, + AUTHMUX_PARALLEL_SYNC_LOCK_WAIT_MS: "0", + AUTHMUX_SKIP_CUE_LAUNCH: "1", + PATH: `${fakeBin}${path.delimiter}${env.PATH ?? ""}`, + SEEN_CONFIG_DIR: seenConfigDir, + }, + ); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stderr, /sync lock is held by another process/); + + const seen = await fsp.readFile(seenConfigDir, "utf8"); + assert.equal(await fsp.readFile(path.join(accountDir, ".credentials.json"), "utf8"), oldCredentials); + assert.equal(await fsp.readFile(path.join(seen, ".credentials.json"), "utf8"), newCredentials); + }); +}); + +test("parallel --run preserves session auth when another session already changed the profile", async () => { + await withSandbox(async (env) => { + const added = runCli(["parallel", "--add", "account2", "--json"], env); + assert.equal(added.status, 0, added.stderr); + + const accountsDir = path.join(env.HOME as string, ".claude-accounts"); + const accountDir = path.join(accountsDir, "account2"); + const oldCredentials = JSON.stringify({ claudeAiOauth: { accessToken: "old-access-token" } }); + const oldClaudeState = JSON.stringify({ oauthAccount: { accountUuid: "old-claude-account" } }); + const sessionCredentials = JSON.stringify({ claudeAiOauth: { accessToken: "session-access-token" } }); + const sessionClaudeState = JSON.stringify({ oauthAccount: { accountUuid: "session-claude-account" } }); + const otherCredentials = JSON.stringify({ claudeAiOauth: { accessToken: "other-access-token" } }); + const otherClaudeState = JSON.stringify({ oauthAccount: { accountUuid: "other-claude-account" } }); + await fsp.writeFile(path.join(accountDir, ".credentials.json"), oldCredentials); + await fsp.writeFile(path.join(accountDir, ".claude.json"), oldClaudeState); + + const fakeBin = path.join(env.HOME as string, "bin"); + await fsp.mkdir(fakeBin, { recursive: true }); + const seenConfigDir = path.join(env.HOME as string, "seen-config-dir.txt"); + const fakeClaude = path.join(fakeBin, "claude"); + await fsp.writeFile( + fakeClaude, + [ + "#!/bin/sh", + "set -eu", + "printf '%s' \"$CLAUDE_CONFIG_DIR\" > \"$SEEN_CONFIG_DIR\"", + `printf '%s' '${otherCredentials}' > "$CANONICAL_DIR/.credentials.json"`, + `printf '%s' '${otherClaudeState}' > "$CANONICAL_DIR/.claude.json"`, + `printf '%s' '${sessionCredentials}' > "$CLAUDE_CONFIG_DIR/.credentials.json"`, + `printf '%s' '${sessionClaudeState}' > "$CLAUDE_CONFIG_DIR/.claude.json"`, + ].join("\n") + "\n", + ); + await fsp.chmod(fakeClaude, 0o755); + + const run = runCli( + ["parallel", "--run", "account2", "--", "--probe-arg"], + { + ...env, + AUTHMUX_SKIP_CUE_LAUNCH: "1", + CANONICAL_DIR: accountDir, + PATH: `${fakeBin}${path.delimiter}${env.PATH ?? ""}`, + SEEN_CONFIG_DIR: seenConfigDir, + }, + ); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stderr, /canonical profile changed in another session/); + + const seen = await fsp.readFile(seenConfigDir, "utf8"); + assert.equal(await fsp.readFile(path.join(accountDir, ".credentials.json"), "utf8"), otherCredentials); + assert.equal(await fsp.readFile(path.join(seen, ".credentials.json"), "utf8"), sessionCredentials); + }); +}); + +test("parallel --sessions lists and --clean-sessions removes stale inactive sessions", async () => { + await withSandbox(async (env) => { + const added = runCli(["parallel", "--add", "account2", "--json"], env); + assert.equal(added.status, 0, added.stderr); + + const sessionDir = path.join( + env.HOME as string, + ".claude-accounts-sessions", + "account2", + "20200101T000000000Z-999999", + ); + await fsp.mkdir(sessionDir, { recursive: true }); + await fsp.writeFile( + path.join(sessionDir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "stale-token" } }), + ); + await fsp.writeFile( + path.join(sessionDir, ".claude.json"), + JSON.stringify({ oauthAccount: { accountUuid: "stale-uuid" } }), + ); + const staleTime = new Date("2020-01-01T00:00:00Z"); + await fsp.utimes(sessionDir, staleTime, staleTime); + + const listed = runCli(["parallel", "--sessions", "--json"], env); + assert.equal(listed.status, 0, listed.stderr); + const parsedList = JSON.parse(listed.stdout.trim()) as { + ok: true; + data: { sessions: Array<{ profile: string; active: boolean; credentialsPresent: boolean; claudeAccountUuid?: string }> }; + }; + assert.equal(parsedList.data.sessions.length, 1); + assert.equal(parsedList.data.sessions[0].profile, "account2"); + assert.equal(parsedList.data.sessions[0].active, false); + assert.equal(parsedList.data.sessions[0].credentialsPresent, true); + assert.equal(parsedList.data.sessions[0].claudeAccountUuid, "stale-uuid"); + + const cleaned = runCli( + ["parallel", "--clean-sessions", "--json"], + { + ...env, + AUTHMUX_PARALLEL_STALE_SESSION_MS: "0", + }, + ); + assert.equal(cleaned.status, 0, cleaned.stderr); + const parsedClean = JSON.parse(cleaned.stdout.trim()) as { + ok: true; + data: { removed: unknown[]; kept: unknown[] }; + }; + assert.equal(parsedClean.data.removed.length, 1); + assert.equal(parsedClean.data.kept.length, 0); + await assert.rejects(fsp.stat(sessionDir), /ENOENT/); + }); +}); + +test("parallel --doctor reports installed session-isolated fish wrappers", async () => { + await withSandbox(async (env) => { + const added = runCli(["parallel", "--add", "account2", "--json"], env); + assert.equal(added.status, 0, added.stderr); + const installed = runCli(["parallel", "--install", "--shell", "fish", "--json"], env); + assert.equal(installed.status, 0, installed.stderr); + + const doctor = runCli(["parallel", "--doctor", "--shell", "fish", "--json"], env); + assert.equal(doctor.status, 0, doctor.stderr); + const parsed = JSON.parse(doctor.stdout.trim()) as { + ok: true; + data: { checks: Array<{ name: string; status: string; message: string }> }; + }; + const wrapper = parsed.data.checks.find((check) => check.name === "wrapper:account2"); + const parallelRun = parsed.data.checks.find((check) => check.name === "parallel-run"); + assert.equal(wrapper?.status, "ok"); + assert.match(wrapper?.message ?? "", /session-isolated runner/); + assert.equal(parallelRun?.status, "ok"); }); });