From 9d2442f9fa772e1048b8153b8a2d586a4ec032ce Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 14 Jan 2026 16:01:56 -0500 Subject: [PATCH 1/5] fix(cliproxy): move token files when pausing/resuming accounts Pausing an account now physically moves the token file to auth/paused/ subdirectory, preventing CLIProxyAPI from discovering and using it. Resume moves the file back to auth/ directory. Changes: - Add getPausedDir() helper for paused tokens location - Update pauseAccount() to move token to paused/ subdir - Update resumeAccount() to move token back to auth/ - Update syncRegistryWithTokenFiles() to check both directories - Update removeAccount() to clean up from both directories - Update getAccountTokenPath() to return correct path based on state Fixes #337 --- src/cliproxy/account-manager.ts | 66 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index fc450c7b..614aa507 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -135,6 +135,14 @@ export function getAccountsRegistryPath(): string { return path.join(getCliproxyDir(), 'accounts.json'); } +/** + * Get path to paused tokens directory + * Paused tokens are moved here so CLIProxyAPI won't discover them + */ +export function getPausedDir(): string { + return path.join(getAuthDir(), 'paused'); +} + /** * Load accounts registry */ @@ -176,10 +184,12 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void { /** * Sync registry with actual token files * Removes stale entries where token file no longer exists + * For paused accounts, checks both auth/ and paused/ directories * Called automatically when loading accounts */ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean { const authDir = getAuthDir(); + const pausedDir = getPausedDir(); let modified = false; for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) { @@ -189,7 +199,14 @@ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean { for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) { const tokenPath = path.join(authDir, meta.tokenFile); - if (!fs.existsSync(tokenPath)) { + const pausedPath = path.join(pausedDir, meta.tokenFile); + + // For paused accounts, check paused dir; for active accounts, check auth dir + const expectedPath = meta.paused ? pausedPath : tokenPath; + // Also accept if file exists in either location (handles edge cases) + const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath); + + if (!fs.existsSync(expectedPath) && !existsAnywhere) { staleIds.push(accountId); } } @@ -395,6 +412,7 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string) /** * Pause an account (skip in quota rotation) + * Moves token file to paused/ subdir so CLIProxyAPI won't discover it */ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean { const registry = loadAccountsRegistry(); @@ -404,6 +422,21 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo return false; } + const accountMeta = providerAccounts.accounts[accountId]; + const authDir = getAuthDir(); + const pausedDir = getPausedDir(); + const tokenPath = path.join(authDir, accountMeta.tokenFile); + const pausedPath = path.join(pausedDir, accountMeta.tokenFile); + + // Move token file to paused directory (if it exists in auth dir) + if (fs.existsSync(tokenPath)) { + // Create paused directory if it doesn't exist + if (!fs.existsSync(pausedDir)) { + fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 }); + } + fs.renameSync(tokenPath, pausedPath); + } + providerAccounts.accounts[accountId].paused = true; providerAccounts.accounts[accountId].pausedAt = new Date().toISOString(); saveAccountsRegistry(registry); @@ -412,6 +445,7 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo /** * Resume a paused account + * Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it */ export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean { const registry = loadAccountsRegistry(); @@ -421,6 +455,17 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo return false; } + const accountMeta = providerAccounts.accounts[accountId]; + const authDir = getAuthDir(); + const pausedDir = getPausedDir(); + const tokenPath = path.join(authDir, accountMeta.tokenFile); + const pausedPath = path.join(pausedDir, accountMeta.tokenFile); + + // Move token file back from paused directory (if it exists in paused dir) + if (fs.existsSync(pausedPath)) { + fs.renameSync(pausedPath, tokenPath); + } + providerAccounts.accounts[accountId].paused = false; providerAccounts.accounts[accountId].pausedAt = undefined; saveAccountsRegistry(registry); @@ -474,11 +519,12 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo return false; } - // Get token file to delete + // Get token file to delete (check both auth and paused directories) const tokenFile = providerAccounts.accounts[accountId].tokenFile; const tokenPath = path.join(getAuthDir(), tokenFile); + const pausedPath = path.join(getPausedDir(), tokenFile); - // Delete token file + // Delete token file from auth directory if (fs.existsSync(tokenPath)) { try { fs.unlinkSync(tokenPath); @@ -487,6 +533,15 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo } } + // Also delete from paused directory if it exists there + if (fs.existsSync(pausedPath)) { + try { + fs.unlinkSync(pausedPath); + } catch { + // Ignore deletion errors + } + } + // Remove from registry delete providerAccounts.accounts[accountId]; @@ -547,6 +602,7 @@ export function touchAccount(provider: CLIProxyProvider, accountId: string): voi /** * Get token file path for an account + * Returns path in paused/ dir if account is paused, otherwise auth/ */ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null { const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider); @@ -555,7 +611,9 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri return null; } - return path.join(getAuthDir(), account.tokenFile); + // Return path from paused directory if account is paused + const baseDir = account.paused ? getPausedDir() : getAuthDir(); + return path.join(baseDir, account.tokenFile); } /** From d87a6531952313b1e3795feb67ab152f2bfbb1e9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 15 Jan 2026 11:50:35 -0500 Subject: [PATCH 2/5] fix(cliproxy): add try-catch for file operations in pause/resume Address PR review feedback: - Wrap fs.renameSync() in try-catch for pauseAccount/resumeAccount - Wrap fs.mkdirSync() in try-catch for paused directory creation - Add idempotent checks (skip if already paused/active) - Rely on syncRegistryWithTokenFiles() for recovery on failure Follows same error handling pattern as removeAccount(). --- src/cliproxy/account-manager.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 614aa507..b3ffbd1e 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -423,6 +423,12 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo } const accountMeta = providerAccounts.accounts[accountId]; + + // Skip if already paused (idempotent) + if (accountMeta.paused) { + return true; + } + const authDir = getAuthDir(); const pausedDir = getPausedDir(); const tokenPath = path.join(authDir, accountMeta.tokenFile); @@ -430,11 +436,16 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo // Move token file to paused directory (if it exists in auth dir) if (fs.existsSync(tokenPath)) { - // Create paused directory if it doesn't exist - if (!fs.existsSync(pausedDir)) { - fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 }); + try { + // Create paused directory if it doesn't exist + if (!fs.existsSync(pausedDir)) { + fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 }); + } + fs.renameSync(tokenPath, pausedPath); + } catch { + // File operation failed, but continue with registry update + // syncRegistryWithTokenFiles() will handle recovery on next load } - fs.renameSync(tokenPath, pausedPath); } providerAccounts.accounts[accountId].paused = true; @@ -456,6 +467,12 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo } const accountMeta = providerAccounts.accounts[accountId]; + + // Skip if already active (idempotent) + if (!accountMeta.paused) { + return true; + } + const authDir = getAuthDir(); const pausedDir = getPausedDir(); const tokenPath = path.join(authDir, accountMeta.tokenFile); @@ -463,7 +480,12 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo // Move token file back from paused directory (if it exists in paused dir) if (fs.existsSync(pausedPath)) { - fs.renameSync(pausedPath, tokenPath); + try { + fs.renameSync(pausedPath, tokenPath); + } catch { + // File operation failed, but continue with registry update + // syncRegistryWithTokenFiles() will handle recovery on next load + } } providerAccounts.accounts[accountId].paused = false; From 4d31128b63ad3996dcb783cd08d956d53ff7face Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 15 Jan 2026 12:41:44 -0500 Subject: [PATCH 3/5] fix(cliproxy): use sibling auth-paused/ dir to prevent token refresh loops CLIProxyAPI's watcher uses filepath.Walk() which recursively scans all subdirectories of auth/. Moving paused tokens to auth/paused/ subdirectory didn't hide them from CLIProxyAPI, causing token refresh loops where paused tokens were immediately recreated. Solution: Use auth-paused/ as a sibling directory instead of auth/paused/ subdirectory. This places paused tokens completely outside CLIProxyAPI's scan path, preventing token discovery and refresh. Path change: - Before: ~/.ccs/cliproxy/auth/paused/ - After: ~/.ccs/cliproxy/auth-paused/ --- src/cliproxy/account-manager.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index b3ffbd1e..51839d35 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -6,6 +6,7 @@ * * Account storage: ~/.ccs/cliproxy/accounts.json * Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field) + * Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path) */ import * as fs from 'fs'; @@ -138,9 +139,14 @@ export function getAccountsRegistryPath(): string { /** * Get path to paused tokens directory * Paused tokens are moved here so CLIProxyAPI won't discover them + * + * Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/) + * because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans + * all subdirectories of auth/. A sibling directory is completely outside + * CLIProxyAPI's scan path, preventing token refresh loops. */ export function getPausedDir(): string { - return path.join(getAuthDir(), 'paused'); + return path.join(getCliproxyDir(), 'auth-paused'); } /** From a931bc9745572c0b5ddb488f568f1bec62d69a25 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 15 Jan 2026 12:57:37 -0500 Subject: [PATCH 4/5] fix(cliproxy): show clear message for paused accounts in Live Monitor - Check isAccountPaused() before readAuthData() in fetchAccountQuota() - Return "Account is paused" instead of confusing "Auth file not found" - Improves UX by explaining why quota fetch fails for paused accounts --- src/cliproxy/quota-fetcher.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index b009da92..df0accb9 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -11,6 +11,7 @@ import { getAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; import { getProviderAccounts, + isAccountPaused, setAccountTier, type AccountInfo, type AccountTier, @@ -553,6 +554,18 @@ export async function fetchAccountQuota( }; } + // Check if account is paused (token moved to auth-paused/ directory) + if (isAccountPaused(provider, accountId)) { + const error = 'Account is paused'; + if (verbose) console.error(`[i] ${error}`); + return { + success: false, + models: [], + lastUpdated: Date.now(), + error, + }; + } + // Read auth data from auth file const authData = readAuthData(provider, accountId); if (!authData) { From 502b30a589c8aef948e8d58ffc543fcb4e0248ad Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 15 Jan 2026 13:04:17 -0500 Subject: [PATCH 5/5] fix(ui): improve paused account display in Live Account Monitor - Add Pause icon with amber color for paused accounts - Show styled "PAUSED" badge instead of plain error text - Distinguish paused state from other errors visually --- .../components/account/flow-viz/account-card.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 1b07c358..7f7d2bd7 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -10,7 +10,7 @@ import { getMinClaudeQuota, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical, Loader2, Clock } from 'lucide-react'; +import { GripVertical, Loader2, Clock, Pause } from 'lucide-react'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -202,9 +202,16 @@ export function AccountCard({ ) : quota?.error ? ( -
- {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} -
+ quota.error === 'Account is paused' ? ( +
+ + Paused +
+ ) : ( +
+ {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} +
+ ) ) : null} )}