From 24847f5804ca258b597b7f367750315b9abfa9f8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 3 Jan 2026 16:31:20 -0500 Subject: [PATCH 01/30] feat(quota): add fetchAllProviderQuotas and findAvailableAccount Add functions for multi-account quota fetching and auto-failover support: - fetchAllProviderQuotas: Fetch quota for all accounts with project grouping - findAvailableAccount: Find account with remaining quota for auto-switch - readProjectIdFromAuthFile: Quick project ID read without API call Detects accounts sharing same GCP project (failover won't help). Part of #252 antigravity failover enhancement. --- src/cliproxy/quota-fetcher.ts | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 749d9393..0291d9b9 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -9,6 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { CLIProxyProvider } from './types'; +import { getProviderAccounts, type AccountInfo } from './account-manager'; /** Individual model quota info */ export interface ModelQuota { @@ -40,6 +41,10 @@ export interface QuotaResult { expiresAt?: string; /** True if account hasn't been activated in official Antigravity app */ isUnprovisioned?: boolean; + /** Account ID (email) this quota belongs to */ + accountId?: string; + /** GCP project ID for this account */ + projectId?: string; } /** Google Cloud Code API endpoints */ @@ -523,3 +528,120 @@ export async function fetchAccountQuota( return result; } + +/** + * Read project ID directly from auth file without making API call + * Used for quick project ID comparison in doctor command + */ +export function readProjectIdFromAuthFile( + provider: CLIProxyProvider, + accountId: string +): string | null { + const authData = readAuthData(provider, accountId); + return authData?.projectId || null; +} + +/** Result for all accounts of a provider */ +export interface AllAccountsQuotaResult { + /** Provider name */ + provider: CLIProxyProvider; + /** Results per account */ + accounts: Array<{ + account: AccountInfo; + quota: QuotaResult; + }>; + /** Accounts grouped by project ID (for detecting shared projects) */ + projectGroups: Record; + /** Timestamp of fetch */ + lastUpdated: number; +} + +/** + * Fetch quota for all accounts of a provider + * Also detects accounts sharing same GCP project (failover won't help) + * + * @param provider - Provider name (only 'agy' supported for quota) + * @returns Results for all accounts with project grouping + */ +export async function fetchAllProviderQuotas( + provider: CLIProxyProvider +): Promise { + const accounts = getProviderAccounts(provider); + const results: AllAccountsQuotaResult = { + provider, + accounts: [], + projectGroups: {}, + lastUpdated: Date.now(), + }; + + if (accounts.length === 0) { + return results; + } + + // Fetch quota for each account in parallel + const quotaPromises = accounts.map(async (account) => { + const quota = await fetchAccountQuota(provider, account.id); + + // Read project ID from auth file if not in quota result + let projectId = quota.projectId; + if (!projectId) { + projectId = readProjectIdFromAuthFile(provider, account.id) || undefined; + } + + return { + account, + quota: { ...quota, accountId: account.id, projectId }, + }; + }); + + const quotaResults = await Promise.all(quotaPromises); + + // Build project groups for detecting shared projects + for (const { account, quota } of quotaResults) { + results.accounts.push({ account, quota }); + + if (quota.projectId) { + if (!results.projectGroups[quota.projectId]) { + results.projectGroups[quota.projectId] = []; + } + results.projectGroups[quota.projectId].push(account.id); + } + } + + return results; +} + +/** + * Find available account with remaining quota + * Used by preflight check for auto-switching + * + * @param provider - Provider name + * @param excludeAccountId - Account to exclude (current exhausted account) + * @returns Account with available quota, or null if none available + */ +export async function findAvailableAccount( + provider: CLIProxyProvider, + excludeAccountId?: string +): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { + const allQuotas = await fetchAllProviderQuotas(provider); + + for (const { account, quota } of allQuotas.accounts) { + // Skip excluded account + if (excludeAccountId && account.id === excludeAccountId) { + continue; + } + + // Skip failed quota fetches + if (!quota.success) { + continue; + } + + // Check if any model has remaining quota (> 5% to avoid edge cases) + const hasQuota = quota.models.some((m) => m.percentage > 5); + if (hasQuota) { + return { account, quota }; + } + } + + return null; +} From 944f5c0fb07bcf293a164b81886595aeb8217703 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 3 Jan 2026 16:34:49 -0500 Subject: [PATCH 02/30] feat(cliproxy): add doctor subcommand for quota diagnostics Add `ccs cliproxy doctor` command that: - Fetches quota for all Antigravity accounts in parallel - Shows per-account quota status with visual bars - Detects shared GCP projects (critical failover limitation) - Warns when accounts share same quota pool Part of #252 antigravity failover enhancement. --- src/commands/cliproxy-command.ts | 113 +++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index e0e93e35..16fc1b18 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -21,6 +21,7 @@ import * as path from 'path'; import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler'; import { getProviderAccounts } from '../cliproxy/account-manager'; +import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher'; import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector'; import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog'; @@ -548,6 +549,7 @@ async function showHelp(): Promise { [ ['status', 'Show running CLIProxy status'], ['stop', 'Stop running CLIProxy instance'], + ['doctor', 'Quota diagnostics and shared project detection'], ], ], [ @@ -579,6 +581,112 @@ async function showHelp(): Promise { console.log(''); } +// ============================================================================ +// DOCTOR COMMAND - Quota diagnostics and shared project detection +// ============================================================================ + +async function handleDoctor(): Promise { + await initUI(); + console.log(header('CLIProxy Quota Diagnostics')); + console.log(''); + + // Check each OAuth provider (agy is the only one with quota) + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + if (accounts.length === 0) { + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + return; + } + + console.log(subheader(`Antigravity Accounts (${accounts.length})`)); + console.log(''); + + // Fetch quota for all accounts + console.log(dim('Fetching quotas...')); + const quotaResult = await fetchAllProviderQuotas(provider); + + // Display per-account quota status + for (const { account, quota } of quotaResult.accounts) { + const accountLabel = account.email || account.id; + const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultBadge}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + if (quota.isUnprovisioned) { + console.log( + ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` + ); + } + console.log(''); + continue; + } + + // Calculate overall quota health + const avgQuota = quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + // Show model quotas + for (const model of quota.models) { + const bar = formatQuotaBar(model.percentage); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + } + console.log(''); + } + + // Check for shared GCP projects (critical warning) + const sharedProjects = Object.entries(quotaResult.projectGroups).filter( + ([, accountIds]) => accountIds.length > 1 + ); + + if (sharedProjects.length > 0) { + console.log(''); + console.log(subheader('Shared Project Warning')); + console.log(''); + for (const [projectId, accountIds] of sharedProjects) { + console.log( + fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) + ); + for (const accountId of accountIds) { + console.log(` - ${accountId}`); + } + console.log(''); + console.log(warn('These accounts share the same quota pool!')); + console.log(warn('Failover between them will NOT help when quota is exhausted.')); + console.log(info('Solution: Use accounts from different GCP projects.')); + } + } + + // Summary + console.log(''); + console.log(subheader('Summary')); + const healthyAccounts = quotaResult.accounts.filter( + ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) + ); + console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); + if (sharedProjects.length > 0) { + console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); + } else if (accounts.length > 1) { + console.log(` ${ok('No shared projects (failover fully operational)')}`); + } + console.log(''); +} + +function formatQuotaBar(percentage: number): string { + const width = 20; + const filled = Math.round((percentage / 100) * width); + const empty = width - filled; + const filledChar = percentage > 50 ? '█' : percentage > 10 ? '▓' : '░'; + return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; +} + // ============================================================================ // MAIN ROUTER // ============================================================================ @@ -617,6 +725,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + if (command === 'doctor' || command === 'diag') { + await handleDoctor(); + return; + } + const installIdx = args.indexOf('--install'); if (installIdx !== -1) { const version = args[installIdx + 1]; From c85ff74f3cdd9b346d1d4d929c29104ab16c658f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 3 Jan 2026 16:44:25 -0500 Subject: [PATCH 03/30] feat(agy): add preflight quota check with auto-switch Before launching Claude CLI for Antigravity, check if current account has remaining quota. If exhausted, auto-switch to alternative account with available quota. Prevents session failures mid-use. --- src/cliproxy/cliproxy-executor.ts | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0de849d6..6a031402 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -58,6 +58,7 @@ import { import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { fetchAccountQuota, findAvailableAccount } from './quota-fetcher'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -463,6 +464,40 @@ export async function execClaudeWithCLIProxy( } } + // 3b. Preflight quota check - auto-switch to account with quota before launch + // Only for agy (Antigravity) which has quota tracking + if (provider === 'agy') { + const defaultAccount = getDefaultAccount(provider); + if (defaultAccount) { + log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`); + const quota = await fetchAccountQuota(provider, defaultAccount.id); + + // Check if current account is exhausted (no model with >5% quota) + const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5); + + if (!hasQuota && quota.success) { + // Current account exhausted, try to find alternative + log('Current account quota exhausted, searching for alternatives...'); + const alternative = await findAvailableAccount(provider, defaultAccount.id); + + if (alternative) { + // Auto-switch to account with remaining quota + setDefaultAccount(provider, alternative.account.id); + touchAccount(provider, alternative.account.id); + console.log( + info( + `Auto-switched to ${alternative.account.email || alternative.account.id} (current account quota exhausted)` + ) + ); + } else { + // No alternatives available - warn but continue + console.log(warn('All accounts appear quota-exhausted')); + console.log(` Run: ccs cliproxy doctor`); + } + } + } + } + // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants From 981cef82119359384e7385aff1791b0afa4f4fc1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 3 Jan 2026 01:09:49 +0000 Subject: [PATCH 04/30] feat(dev): add symlink setup for testing dev version Add dev:symlink and dev:unlink scripts to enable seamless testing of development changes using the global 'ccs' command without needing to pack/install globally each time. - scripts/dev-symlink.sh: New script that safely creates symlinks from global ccs to dev dist/ccs.js with backup/restore functionality - package.json: Added dev:symlink and dev:unlink npm scripts - CONTRIBUTING.md: Updated development setup documentation with symlink workflow option This improves developer experience by allowing immediate testing of changes with 'ccs ' instead of './dist/ccs.js '. --- CLAUDE.md | 4 +- CONTRIBUTING.md | 16 +++++- package.json | 2 + scripts/dev-symlink.sh | 115 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100755 scripts/dev-symlink.sh diff --git a/CLAUDE.md b/CLAUDE.md index e85547cf..1f41c7bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -268,7 +268,9 @@ bun run test:unit # Unit tests ### Local Development ```bash bun run dev # Build + start config server (http://localhost:3000) -./scripts/dev-install.sh # Build, pack, install globally +bun run dev:symlink # Symlink global 'ccs' → dev dist/ccs.js (fast iteration) +bun run dev:unlink # Restore original global ccs +./scripts/dev-install.sh # Build, pack, install globally (full install) rm -rf ~/.ccs # Clean environment ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe0a7c50..e45bcb1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -265,8 +265,22 @@ cd ccs # Create feature branch git checkout -b your-feature-name +# Option 1: Test with built binary +# Test locally with ./dist/ccs.js + +# Option 2: Symlink for seamless testing (recommended) +bun run build +bun run dev:symlink # Symlinks global 'ccs' to dev version +# Now 'ccs' command uses your dev changes! + # Make changes -# Test locally with ./ccs +# Test with: ccs + +# When done developing: +bun run dev:unlink # Restores original global ccs + +# Run tests +# Test with: ccs # Run tests bun run test # All tests diff --git a/package.json b/package.json index 2efabc7e..e95ba759 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "dev": "bun run build:server && bun dist/ccs.js config --dev", + "dev:symlink": "bash scripts/dev-symlink.sh", + "dev:unlink": "bash scripts/dev-symlink.sh --restore", "ui:build": "cd ui && bun run build", "ui:preview": "cd ui && bun run preview", "ui:validate": "cd ui && bun run validate", diff --git a/scripts/dev-symlink.sh b/scripts/dev-symlink.sh new file mode 100755 index 00000000..7f6ada59 --- /dev/null +++ b/scripts/dev-symlink.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# CCS Dev Symlink Setup +# Creates symlinks for testing dev version with 'ccs' command +# +# Usage: ./scripts/dev-symlink.sh [--restore] +# +# Without --restore: Creates symlink from global 'ccs' to dist/ccs.js +# With --restore: Restores original global 'ccs' from backup + +set -euo pipefail + +RESTORE=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --restore) RESTORE=true ;; + -h|--help) + echo "Usage: $0 [--restore]" + echo "" + echo "Create symlink for dev testing:" + echo " $0" + echo "" + echo "Restore original global ccs:" + echo " $0 --restore" + exit 0 + ;; + *) + echo "[X] Unknown option: $arg" + echo "Use --help for usage" + exit 1 + ;; + esac +done + +# Get to the right directory +cd "$(dirname "$0")/.." + +# Check if dist/ccs.js exists +if [ ! -f "dist/ccs.js" ]; then + echo "[X] ERROR: dist/ccs.js not found. Run 'bun run build' first." + exit 1 +fi + +# Get absolute path to dev ccs.js +DEV_CCS_PATH="$(pwd)/dist/ccs.js" + +# Find global ccs installation +GLOBAL_CCS_PATH=$(which ccs 2>/dev/null || true) + +if [ -z "$GLOBAL_CCS_PATH" ]; then + echo "[X] ERROR: No global 'ccs' installation found." + echo "Install CCS globally first: npm install -g @kaitranntt/ccs" + exit 1 +fi + +echo "[i] Found global ccs at: $GLOBAL_CCS_PATH" + +if [ "$RESTORE" = true ]; then + # Restore original ccs from backup + BACKUP_PATH="${GLOBAL_CCS_PATH}.backup-dev" + + if [ ! -f "$BACKUP_PATH" ] && [ ! -L "$BACKUP_PATH" ]; then + echo "[X] ERROR: No backup found at $BACKUP_PATH" + echo "Cannot restore - backup may have been deleted" + exit 1 + fi + + echo "[i] Restoring original ccs from backup..." + rm -f "$GLOBAL_CCS_PATH" + if [ -L "$BACKUP_PATH" ]; then + # Restore symlink + cp -P "$BACKUP_PATH" "$GLOBAL_CCS_PATH" + else + # Restore regular file + cp "$BACKUP_PATH" "$GLOBAL_CCS_PATH" + fi + chmod +x "$GLOBAL_CCS_PATH" + rm -f "$BACKUP_PATH" + + echo "[OK] Restored original global ccs" + echo "Run 'ccs --version' to verify" + exit 0 +fi + +# Check if already symlinked to our dev version +if [ -L "$GLOBAL_CCS_PATH" ]; then + CURRENT_TARGET=$(readlink "$GLOBAL_CCS_PATH" 2>/dev/null || true) + if [ "$CURRENT_TARGET" = "$DEV_CCS_PATH" ]; then + echo "[OK] Already symlinked to dev version" + exit 0 + fi +fi + +# Create backup of current global ccs +BACKUP_PATH="${GLOBAL_CCS_PATH}.backup-dev" +if [ -f "$BACKUP_PATH" ] || [ -L "$BACKUP_PATH" ]; then + echo "[i] Backup already exists, skipping backup creation" +else + echo "[i] Creating backup of current global ccs..." + cp -P "$GLOBAL_CCS_PATH" "$BACKUP_PATH" + echo "[OK] Backup created at: $BACKUP_PATH" +fi + +# Create symlink +echo "[i] Creating symlink to dev version..." +rm -f "$GLOBAL_CCS_PATH" +ln -s "$DEV_CCS_PATH" "$GLOBAL_CCS_PATH" + +echo "[OK] Symlinked global 'ccs' to dev version" +echo "" +echo "Now you can test dev changes with: ccs " +echo "To restore original: $0 --restore" +echo "" +echo "Test with: ccs --version" From 3a40a0d015c42c25d155153cb124da8e6518305b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 07:47:13 +0000 Subject: [PATCH 05/30] chore(release): 7.13.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e95ba759..1f3c0d63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1", + "version": "7.13.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c9cdfd98792cc6d272aa22e2317a3a3bb32105de Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 11:40:32 -0500 Subject: [PATCH 06/30] feat(agy): promote gemini-claude-sonnet-4-5 as default Haiku model Closes #270 --- config/base-agy.settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/base-agy.settings.json b/config/base-agy.settings.json index 0b08be0b..398841bc 100644 --- a/config/base-agy.settings.json +++ b/config/base-agy.settings.json @@ -5,6 +5,6 @@ "ANTHROPIC_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview", "ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-3-flash-preview" + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-claude-sonnet-4-5" } } From 869ab3eecd97de2a84c18c5ad25fe2abf0bdb088 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 11:41:52 -0500 Subject: [PATCH 07/30] fix(cliproxy): cap auto-update to v80 due to v81+ context bugs CLIProxyAPIPlus v81+ has context cancellation bugs causing: - Intermittent 500 errors - "context canceled" errors during streaming - Broken token refresh handling Root cause: v81 commit 7a77b23 changed refreshToken to use detached context.Background() causing race conditions. Solution: Add CLIPROXY_MAX_STABLE_VERSION constant (6.6.80-0) and clamp auto-update to this version until upstream fixes. Closes #269 --- src/cliproxy/binary/lifecycle.ts | 30 +++++++++++++++++++++++++----- src/cliproxy/platform-detector.ts | 7 +++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 30156777..effbfda5 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -10,19 +10,38 @@ import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; import { info } from '../../utils/ui'; import { isCliproxyRunning } from '../stats-fetcher'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector'; /** Log helper */ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } +/** + * Clamp version to max stable if newer versions are unstable + */ +function clampToMaxStable(version: string, verbose: boolean): string { + if (isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION)) { + log(`Clamping ${version} to max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); + return CLIPROXY_MAX_STABLE_VERSION; + } + return version; +} + /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const updateResult = await checkForUpdates(config.binPath, config.version, verbose); if (!updateResult.hasUpdate) return; + // Clamp to max stable version + const targetVersion = clampToMaxStable(updateResult.latestVersion, verbose); + if (!isNewerVersion(targetVersion, updateResult.currentVersion)) { + log(`Already at max stable version ${updateResult.currentVersion}`, verbose); + return; + } + const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); - const updateMsg = `CLIProxy Plus update available: v${updateResult.currentVersion} -> v${updateResult.latestVersion}`; + const updateMsg = `CLIProxy Plus update available: v${updateResult.currentVersion} -> v${targetVersion}`; if (proxyRunning) { console.log(info(updateMsg)); @@ -32,7 +51,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): console.log(info(updateMsg)); console.log(info('Updating CLIProxy Plus...')); deleteBinary(config.binPath, verbose); - config.version = updateResult.latestVersion; + config.version = targetVersion; await downloadAndInstall(config, verbose); } } @@ -70,9 +89,10 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose); - if (latestVersion && isNewerVersion(latestVersion, config.version)) { - log(`Using latest version: ${latestVersion} (instead of ${config.version})`, verbose); - config.version = latestVersion; + const targetVersion = clampToMaxStable(latestVersion, verbose); + if (targetVersion && isNewerVersion(targetVersion, config.version)) { + log(`Using version: ${targetVersion} (instead of ${config.version})`, verbose); + config.version = targetVersion; } } catch { log(`Using pinned version: ${config.version}`, verbose); diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index 1d8333b2..bbe76688 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -14,6 +14,13 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './ty */ export const CLIPROXY_FALLBACK_VERSION = '6.6.40-0'; +/** + * Maximum stable version cap - prevents auto-update to known unstable releases + * v81+ has context cancellation bugs causing intermittent 500 errors + * See: https://github.com/kaitranntt/ccs/issues/269 + */ +export const CLIPROXY_MAX_STABLE_VERSION = '6.6.80-0'; + /** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */ export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION; From 96ef62f4ce659dbb1bf33c44739ab638c105a3d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 16:44:50 +0000 Subject: [PATCH 08/30] chore(release): 7.13.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1f3c0d63..66e1f48c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.1", + "version": "7.13.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 212aef81bc68a2d0d146d410d18f7778b8c2c100 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 11:50:06 -0500 Subject: [PATCH 09/30] fix(cliproxy): add edge case handling for version capping - Add isAboveMaxStable() helper for version stability checks - Add null/empty guard in clampToMaxStable() function - Warn users on unstable v81+ without forcing downgrade - Add context note showing latest version when it's unstable - Clamp fallback version on GitHub API failure --- src/cliproxy/binary/lifecycle.ts | 48 ++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index effbfda5..51867a4e 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import { BinaryManagerConfig } from '../types'; import { checkForUpdates, fetchLatestVersion, isNewerVersion } from './version-checker'; import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; -import { info } from '../../utils/ui'; +import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../stats-fetcher'; import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIPROXY_MAX_STABLE_VERSION } from '../platform-detector'; @@ -18,10 +18,22 @@ function log(message: string, verbose: boolean): void { } /** - * Clamp version to max stable if newer versions are unstable + * Check if version is above max stable (known unstable) */ -function clampToMaxStable(version: string, verbose: boolean): string { - if (isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION)) { +function isAboveMaxStable(version: string): boolean { + return isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); +} + +/** + * Clamp version to max stable if newer versions are unstable + * Returns max stable version if input is empty/invalid + */ +function clampToMaxStable(version: string | undefined, verbose: boolean): string { + if (!version) { + log(`Empty version, using max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); + return CLIPROXY_MAX_STABLE_VERSION; + } + if (isAboveMaxStable(version)) { log(`Clamping ${version} to max stable ${CLIPROXY_MAX_STABLE_VERSION}`, verbose); return CLIPROXY_MAX_STABLE_VERSION; } @@ -31,17 +43,32 @@ function clampToMaxStable(version: string, verbose: boolean): string { /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const updateResult = await checkForUpdates(config.binPath, config.version, verbose); + const currentVersion = updateResult.currentVersion; + const latestVersion = updateResult.latestVersion; + + // Check if user is on known unstable version - inform but don't force downgrade + if (isAboveMaxStable(currentVersion)) { + console.log( + warn( + `CLIProxy Plus v${currentVersion} has known stability issues. ` + + `Stable version: v${CLIPROXY_MAX_STABLE_VERSION}` + ) + ); + console.log(info('Run "ccs cliproxy install 80" to downgrade, or wait for upstream fix')); + } + if (!updateResult.hasUpdate) return; // Clamp to max stable version - const targetVersion = clampToMaxStable(updateResult.latestVersion, verbose); - if (!isNewerVersion(targetVersion, updateResult.currentVersion)) { - log(`Already at max stable version ${updateResult.currentVersion}`, verbose); + const targetVersion = clampToMaxStable(latestVersion, verbose); + if (!isNewerVersion(targetVersion, currentVersion)) { + log(`Already at max stable version ${currentVersion}`, verbose); return; } const proxyRunning = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); - const updateMsg = `CLIProxy Plus update available: v${updateResult.currentVersion} -> v${targetVersion}`; + const latestNote = isAboveMaxStable(latestVersion) ? ` (latest v${latestVersion} unstable)` : ''; + const updateMsg = `CLIProxy Plus update: v${currentVersion} -> v${targetVersion}${latestNote}`; if (proxyRunning) { console.log(info(updateMsg)); @@ -95,7 +122,10 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise config.version = targetVersion; } } catch { - log(`Using pinned version: ${config.version}`, verbose); + // API failed - use fallback but still clamp to max stable + const fallbackVersion = clampToMaxStable(config.version, verbose); + config.version = fallbackVersion; + log(`Using fallback version: ${fallbackVersion}`, verbose); } } else { log(`Force version mode: using specified version ${config.version}`, verbose); From c5621dab515ea290e2740cc1fce79e9d65081579 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 12:10:32 -0500 Subject: [PATCH 10/30] feat(cliproxy): add dashboard UI parity for version stability Add version stability warnings to Dashboard API and UI: - Extend /api/cliproxy/update-check with isStable, maxStableVersion, stabilityMessage - Health check shows warning status for v81+ installations - UI header displays version badge with amber warning for unstable Closes: relates to #269 --- src/cliproxy/binary-manager.ts | 22 ++++++++++- src/web-server/health/cliproxy-checks.ts | 17 ++++++++ .../components/cliproxy/cliproxy-header.tsx | 39 ++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 51a33d45..7e52e81a 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -8,7 +8,7 @@ import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; -import { CLIPROXY_FALLBACK_VERSION } from './platform-detector'; +import { CLIPROXY_FALLBACK_VERSION, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; import { isProxyRunning, stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; import { @@ -151,11 +151,29 @@ export interface CliproxyUpdateCheckResult { latestVersion: string; fromCache: boolean; checkedAt: number; + // Stability fields + isStable: boolean; + maxStableVersion: string; + stabilityMessage?: string; } /** Check for CLIProxyAPI binary updates */ export async function checkCliproxyUpdate(): Promise { - return new BinaryManager().checkForUpdates(); + const result = await new BinaryManager().checkForUpdates(); + + // Import isNewerVersion for stability check + const { isNewerVersion } = await import('./binary/version-checker'); + const isStable = !isNewerVersion(result.currentVersion, CLIPROXY_MAX_STABLE_VERSION); + const stabilityMessage = isStable + ? undefined + : `v${result.currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`; + + return { + ...result, + isStable, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + stabilityMessage, + }; } // Re-export version pin functions diff --git a/src/web-server/health/cliproxy-checks.ts b/src/web-server/health/cliproxy-checks.ts index 5c1ee2f1..fb41c7af 100644 --- a/src/web-server/health/cliproxy-checks.ts +++ b/src/web-server/health/cliproxy-checks.ts @@ -15,6 +15,8 @@ import { } from '../../cliproxy'; import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; import type { HealthCheck } from './types'; +import { CLIPROXY_MAX_STABLE_VERSION } from '../../cliproxy/platform-detector'; +import { isNewerVersion } from '../../cliproxy/binary/version-checker'; /** * Check CLIProxy binary installation @@ -23,6 +25,21 @@ export function checkCliproxyBinary(): HealthCheck { if (isCLIProxyInstalled()) { const version = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); + + // Check if version exceeds stable cap + const isUnstable = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); + + if (isUnstable) { + return { + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'warning', + message: `v${version} (unstable)`, + details: binaryPath, + fix: `Downgrade: ccs cliproxy install ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}`, + }; + } + return { id: 'cliproxy-binary', name: 'CLIProxy Binary', diff --git a/ui/src/components/cliproxy/cliproxy-header.tsx b/ui/src/components/cliproxy/cliproxy-header.tsx index a2d39178..956d37ca 100644 --- a/ui/src/components/cliproxy/cliproxy-header.tsx +++ b/ui/src/components/cliproxy/cliproxy-header.tsx @@ -6,11 +6,17 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { RefreshCw, Loader2 } from 'lucide-react'; +import { RefreshCw, Loader2, AlertTriangle } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { cn } from '@/lib/utils'; +interface VersionInfo { + currentVersion: string; + isStable: boolean; + stabilityMessage?: string; +} + interface LoginButtonProps { provider: string; displayName: string; @@ -110,6 +116,22 @@ export function CliproxyHeader({ const { data: authData } = useCliproxyAuth(); const { provider: authProvider, isAuthenticating, startAuth } = useCliproxyAuthFlow(); const lastUpdatedText = useRelativeTime(lastUpdated); + const [versionInfo, setVersionInfo] = useState(null); + + useEffect(() => { + fetch('/api/cliproxy/update-check') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data) { + setVersionInfo({ + currentVersion: data.currentVersion, + isStable: data.isStable, + stabilityMessage: data.stabilityMessage, + }); + } + }) + .catch(() => {}); // Silently fail + }, []); const providers = [ { id: 'claude', displayName: 'Claude' }, @@ -170,6 +192,21 @@ export function CliproxyHeader({ {isRunning ? 'Running' : 'Offline'} + {versionInfo && ( + + {!versionInfo.isStable && }v + {versionInfo.currentVersion} + + )} + {lastUpdatedText && ( {lastUpdatedText} )} From e03d9b77437575a39af0dca14c2a6b5967ae4f09 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 11:59:59 -0500 Subject: [PATCH 11/30] fix(websearch): use 'where' command on Windows for CLI detection Fixes #273 The websearch-transformer hook used hardcoded 'which' command which doesn't exist on Windows. Now uses process.platform detection to choose 'where' on Windows and 'which' on Unix systems. --- lib/hooks/websearch-transformer.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index b0270543..7a2af2a6 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -154,7 +154,10 @@ process.stdin.on('error', () => { */ function isCliAvailable(cmd) { try { - const result = spawnSync('which', [cmd], { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where.exe' : 'which'; + + const result = spawnSync(whichCmd, [cmd], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], From 6ff5c79fe1123ebf30bbda5fbc1bfaa522d16845 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 17:41:00 +0000 Subject: [PATCH 12/30] chore(release): 7.13.1-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 66e1f48c..f9657cd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.2", + "version": "7.13.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 8a56a43989eb26b22ba28aa091a2bcef97701a20 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 12:46:35 -0500 Subject: [PATCH 13/30] feat(ui): add stability warning to ProxyStatusWidget - Show amber warning indicator when installed version is unstable (v81+) - Version display now shows "(unstable)" suffix with amber styling - "Downgrade" button replaces "Update" when version is unstable - Tooltip explains restart will downgrade to stable version --- .../monitoring/proxy-status-widget.tsx | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index f0801c16..78734dc1 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -16,6 +16,7 @@ import { RotateCw, ArrowUp, Globe, + AlertTriangle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -75,6 +76,7 @@ export function ProxyStatusWidget() { const isRunning = status?.running ?? false; const isActioning = startProxy.isPending || stopProxy.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; + const isUnstable = updateCheck?.isStable === false; // Build remote display info const remoteDisplayHost = isRemoteMode @@ -191,29 +193,35 @@ export function ProxyStatusWidget() { {/* Control buttons when running */}
- {/* Control buttons when running */} + {/* Control buttons when running: Restart | Update/Downgrade | Stop | Settings */}
+ {/* Restart button - pure restart, no version change */} + + + {/* Update/Downgrade button - version change */} + + {/* Stop button */}
+ + {/* Version Settings (collapsible) */} + + +
+ {/* Current version */} +
+ Current: + + v{updateCheck?.currentVersion} + {isUnstable && ' (unstable)'} + +
+ + {/* Version picker row */} +
+ {/* Dropdown */} + + + {/* Manual input */} + { + setManualVersion(e.target.value); + setSelectedVersion(''); + }} + className="h-7 text-xs w-24" + /> + + {/* Install button */} + +
+ + {/* Stability warning */} + {(selectedVersion || manualVersion) && + versionsData && + isNewerVersionClient( + manualVersion || selectedVersion, + versionsData.maxStableVersion + ) && ( +
+ + + Versions above {versionsData.maxStableVersion} have known stability issues + +
+ )} +
+
+
) : (
@@ -284,6 +484,38 @@ export function ProxyStatusWidget() { )}
)} + + {/* Unstable Version Confirmation Dialog */} + + + + + + Install Unstable Version? + + +

+ You are about to install v{pendingInstallVersion}, which is above + the maximum stable version{' '} + v{versionsData?.maxStableVersion || '6.6.80'}. +

+

+ This version has known stability issues and may cause unexpected behavior. +

+

Are you sure you want to proceed?

+
+
+ + Cancel + + Install Anyway + + +
+
); } diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d4b6ec9c..13e8efee 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -304,3 +304,59 @@ export function useCliproxyUpdateCheck() { refetchOnWindowFocus: false, // Don't refresh on window focus (save API calls) }); } + +// ==================== Version Management ==================== + +export function useCliproxyVersions() { + return useQuery({ + queryKey: ['cliproxy-versions'], + queryFn: () => api.cliproxy.versions(), + staleTime: 60 * 60 * 1000, // 1 hour (matches backend cache) + refetchOnWindowFocus: false, + }); +} + +export function useInstallVersion() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ version, force }: { version: string; force?: boolean }) => + api.cliproxy.install(version, force), + onSuccess: (data) => { + if (data.requiresConfirmation) { + // Don't show toast - let caller handle confirmation dialog + return; + } + queryClient.invalidateQueries({ queryKey: ['cliproxy-versions'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-update-check'] }); + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(data.message || `Installed v${data.version}`); + } else { + toast.error(data.error || 'Installation failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useRestartProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.restart(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.success) { + toast.success(`Proxy restarted on port ${data.port}`); + } else { + toast.error(data.error || 'Restart failed'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a2cf98a5..55f3519f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -261,6 +261,34 @@ export interface CliproxyUpdateCheckResult { checkedAt: number; // Unix timestamp of last check } +/** Available versions list from GitHub releases */ +export interface CliproxyVersionsResponse { + versions: string[]; + latestStable: string; + latest: string; + currentVersion: string; + maxStableVersion: string; + fromCache: boolean; + checkedAt: number; +} + +/** Result from installing a specific version */ +export interface CliproxyInstallResult { + success: boolean; + version?: string; + isUnstable?: boolean; + requiresConfirmation?: boolean; + message?: string; + error?: string; +} + +/** Result from restarting the proxy */ +export interface CliproxyRestartResult { + success: boolean; + port?: number; + error?: string; +} + // API export const api = { profiles: { @@ -301,6 +329,15 @@ export const api = { proxyStop: () => request('/cliproxy/proxy-stop', { method: 'POST' }), updateCheck: () => request('/cliproxy/update-check'), + // Version management + versions: () => request('/cliproxy/versions'), + install: (version: string, force?: boolean) => + request('/cliproxy/install', { + method: 'POST', + body: JSON.stringify({ version, force }), + }), + restart: () => request('/cliproxy/restart', { method: 'POST' }), + // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), models: () => request('/cliproxy/models'), From 48d4a96a62fecde105f58bd68ca130571ef0daa4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 13:21:31 -0500 Subject: [PATCH 15/30] fix(ui): update/downgrade button now installs correct version The button was incorrectly calling handleRestart (pure restart) instead of triggering version install. Now correctly installs latestVersion for updates or maxStableVersion for downgrades. --- .../components/monitoring/proxy-status-widget.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index ea42c35a..b7cd0a09 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -172,14 +172,6 @@ export function ProxyStatusWidget() { })() : null; - // Restart = stop then start - const handleRestart = async () => { - await stopProxy.mutateAsync(); - // Small delay to ensure port is released - await new Promise((r) => setTimeout(r, 500)); - startProxy.mutate(); - }; - // Remote mode: show remote server info if (isRemoteMode) { return ( @@ -303,7 +295,12 @@ export function ProxyStatusWidget() { : hasUpdate && 'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground' )} - onClick={handleRestart} + onClick={() => { + const targetVersion = isUnstable + ? updateCheck?.maxStableVersion || versionsData?.latestStable + : updateCheck?.latestVersion; + if (targetVersion) handleInstallVersion(targetVersion); + }} disabled={isActioning || (!hasUpdate && !isUnstable)} title={ isUnstable From 5b58bd35c9e2dfd7163bc8eff7804506b49b4872 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 13:27:54 -0500 Subject: [PATCH 16/30] fix(agy): edge case handling for quota failover - Fix formatQuotaBar crash on percentage > 100 or < 0 (clamp to 0-100) - Fix shared project accounts excluded from failover (same GCP project = pooled quota) - Fix division by zero in avgQuota when models array empty - Fix null account label fallback to 'Unknown Account' --- src/cliproxy/quota-fetcher.ts | 9 +++++++++ src/commands/cliproxy-command.ts | 14 +++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 0291d9b9..d2daa236 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -625,6 +625,10 @@ export async function findAvailableAccount( ): Promise<{ account: AccountInfo; quota: QuotaResult } | null> { const allQuotas = await fetchAllProviderQuotas(provider); + // Get excluded account's project ID to avoid switching to same-project accounts + const excludedProjectId = allQuotas.accounts.find((a) => a.account.id === excludeAccountId)?.quota + .projectId; + for (const { account, quota } of allQuotas.accounts) { // Skip excluded account if (excludeAccountId && account.id === excludeAccountId) { @@ -636,6 +640,11 @@ export async function findAvailableAccount( continue; } + // Skip accounts sharing same GCP project (quota is pooled) + if (excludedProjectId && quota.projectId === excludedProjectId) { + continue; + } + // Check if any model has remaining quota (> 5% to avoid edge cases) const hasQuota = quota.models.some((m) => m.percentage > 5); if (hasQuota) { diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index c8af0f25..080d271b 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -609,7 +609,7 @@ async function handleDoctor(): Promise { // Display per-account quota status for (const { account, quota } of quotaResult.accounts) { - const accountLabel = account.email || account.id; + const accountLabel = account.email || account.id || 'Unknown Account'; const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; if (!quota.success) { @@ -624,8 +624,11 @@ async function handleDoctor(): Promise { continue; } - // Calculate overall quota health - const avgQuota = quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length; + // Calculate overall quota health (guard against empty models array) + const avgQuota = + quota.models.length > 0 + ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + : 0; const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); @@ -681,9 +684,10 @@ async function handleDoctor(): Promise { function formatQuotaBar(percentage: number): string { const width = 20; - const filled = Math.round((percentage / 100) * width); + const clampedPct = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clampedPct / 100) * width); const empty = width - filled; - const filledChar = percentage > 50 ? '█' : percentage > 10 ? '▓' : '░'; + const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; } From 8ea1e333bc2b365b7f058201f714e41e822b815f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 13:31:34 -0500 Subject: [PATCH 17/30] docs(agy): add quota management and failover documentation - Add ccs cliproxy doctor command to help text - Document Antigravity auto-failover in README --- README.md | 8 ++++++++ src/commands/help-command.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index a380165f..7a4cd177 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,14 @@ ccs sync Re-creates symlinks for shared commands, skills, and settings. +### Antigravity Quota Management + +```bash +ccs cliproxy doctor # Check quota status for all agy accounts +``` + +**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +
## Configuration diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ddfa96ef..840ad3d5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -256,6 +256,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); printSubSection('CLI Proxy Plus Management', [ ['ccs cliproxy', 'Show CLIProxy Plus status and version'], ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], + ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], ['ccs cliproxy --latest', 'Update to latest version'], ]); From 8072b93b3b2d4bc721fece815c8edf45da67b34b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 13:49:24 -0500 Subject: [PATCH 18/30] refactor(ui): redesign ProxyStatusWidget with two-state UX - Replace 4-button row with 3 icon buttons (restart, stop, settings) - Add tooltips to all icon buttons via TooltipProvider - Move version display to header next to title - Implement collapsed/expanded two-state design - Remove manual version input field (use dropdown only) - Add clickable update/downgrade badge showing target version - Add "Version Management" section header in expanded view - Increase control sizes (h-8) for better touch targets - Settings icon toggles to X when expanded --- .../monitoring/proxy-status-widget.tsx | 557 +++++++++--------- 1 file changed, 276 insertions(+), 281 deletions(-) diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index b7cd0a09..a67db32f 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -4,6 +4,8 @@ * Displays CLIProxy process status with start/stop/restart controls. * Shows: running state, port, session count, uptime, update availability. * In remote mode: shows remote server info instead of local controls. + * + * Design: Two-state widget (collapsed/expanded) with icon-only control buttons. */ import { useState } from 'react'; @@ -20,10 +22,11 @@ import { Globe, AlertTriangle, Settings, + X, + Download, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible'; import { Select, @@ -42,6 +45,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery } from '@tanstack/react-query'; import { api, type CliproxyServerConfig } from '@/lib/api-client'; import { @@ -92,6 +96,53 @@ function formatTimeAgo(timestamp?: number): string { return `${hours}h ago`; } +/** Icon button with tooltip wrapper */ +function IconButton({ + icon: Icon, + tooltip, + onClick, + disabled, + isPending, + className, + variant = 'ghost', +}: { + icon: React.ElementType; + tooltip: string; + onClick: () => void; + disabled?: boolean; + isPending?: boolean; + className?: string; + variant?: 'ghost' | 'outline' | 'destructive-ghost'; +}) { + return ( + + + + + + {tooltip} + + + ); +} + export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); @@ -101,10 +152,9 @@ export function ProxyStatusWidget() { const restartProxy = useRestartProxy(); const installVersion = useInstallVersion(); - // Version picker state - const [showVersionSettings, setShowVersionSettings] = useState(false); + // Version picker state (expanded section) + const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); - const [manualVersion, setManualVersion] = useState(''); // Confirmation dialog state for unstable versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); @@ -129,6 +179,12 @@ export function ProxyStatusWidget() { installVersion.isPending; const hasUpdate = updateCheck?.hasUpdate ?? false; const isUnstable = updateCheck?.isStable === false; + const currentVersion = updateCheck?.currentVersion; + + // Target version for update/downgrade badge + const targetVersion = isUnstable + ? updateCheck?.maxStableVersion || versionsData?.latestStable + : updateCheck?.latestVersion; // Handle version install (shows confirmation for unstable) const handleInstallVersion = (version: string) => { @@ -207,49 +263,104 @@ export function ProxyStatusWidget() { ); } - // Local mode: show original controls - + // Local mode: Two-state widget (collapsed/expanded) return ( -
-
-
-
+
+ {/* Header row: Status dot, title, version, update badge, icon buttons */} +
+
+ {/* Status indicator */} +
+ CLIProxy Plus + + {/* Version in header */} + {currentVersion && ( + + v{currentVersion} + )} - /> - CLIProxy Plus - {hasUpdate && ( - v${updateCheck?.latestVersion}`} - > - - Update - - )} + + {/* Clickable Update/Downgrade badge */} + {(hasUpdate || isUnstable) && targetVersion && ( + handleInstallVersion(targetVersion)} + title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + > + {isUnstable ? ( + <> + + {targetVersion} + + ) : ( + <> + + {targetVersion} + + )} + + )} +
+ + {/* Right side: status icon + control buttons when running */} +
+ {isLoading ? ( + + ) : isRunning ? ( + <> + {/* Icon buttons: Restart, Stop, Settings/Close */} + restartProxy.mutate()} + disabled={isActioning} + isPending={restartProxy.isPending} + /> + stopProxy.mutate()} + disabled={isActioning} + isPending={stopProxy.isPending} + variant="destructive-ghost" + /> + setIsExpanded(!isExpanded)} + className={isExpanded ? 'bg-muted' : undefined} + /> + + ) : ( + + )} +
-
- {isLoading ? ( - - ) : isRunning ? ( - - ) : ( - - )} -
-
- - {isRunning && status ? ( - <> + {/* Stats row when running */} + {isRunning && status && (
Port {status.port} {status.sessionCount !== undefined && status.sessionCount > 0 && ( @@ -265,254 +376,138 @@ export function ProxyStatusWidget() { )}
- {/* Control buttons when running: Restart | Update/Downgrade | Stop | Settings */} -
- {/* Restart button - pure restart, no version change */} + )} + + {/* Expanded section: Version Management */} + {isRunning && ( + + + {/* Section header */} +

Version Management

+ + {/* Version picker row */} +
+ {/* Dropdown - full width, no truncation */} + + + {/* Install button */} + +
+ + {/* Stability warning for selected version */} + {selectedVersion && + versionsData?.maxStableVersion && + isNewerVersionClient(selectedVersion, versionsData.maxStableVersion) && ( +
+ + Versions above {versionsData.maxStableVersion} have known issues +
+ )} + + {/* Sync time */} + {updateCheck?.checkedAt && ( +
+ Last checked {formatTimeAgo(updateCheck.checkedAt)} +
+ )} +
+
+ )} + + {/* Not running state */} + {!isRunning && ( +
+ Not running - - {/* Update/Downgrade button - version change */} - - - {/* Stop button */} - - - {/* Settings gear - toggle version picker */} -
+ )} - {/* Version Settings (collapsible) */} - - -
- {/* Current version */} -
- Current: - - v{updateCheck?.currentVersion} - {isUnstable && ' (unstable)'} - -
- - {/* Version picker row */} -
- {/* Dropdown */} - - - {/* Manual input */} - { - setManualVersion(e.target.value); - setSelectedVersion(''); - }} - className="h-7 text-xs w-24" - /> - - {/* Install button */} - -
- - {/* Stability warning */} - {(selectedVersion || manualVersion) && - versionsData && - isNewerVersionClient( - manualVersion || selectedVersion, - versionsData.maxStableVersion - ) && ( -
- - - Versions above {versionsData.maxStableVersion} have known stability issues - -
- )} -
-
-
- - ) : ( -
- Not running - -
- )} - - {/* Version sync indicator */} - {updateCheck?.currentVersion && ( -
- - {isUnstable && ( - - )} - - v{updateCheck.currentVersion} - - {isUnstable && ( - (unstable) - )} - - {updateCheck.checkedAt && ( - - Synced {formatTimeAgo(updateCheck.checkedAt)} - - )} -
- )} - - {/* Unstable Version Confirmation Dialog */} - - - - - - Install Unstable Version? - - -

- You are about to install v{pendingInstallVersion}, which is above - the maximum stable version{' '} - v{versionsData?.maxStableVersion || '6.6.80'}. -

-

- This version has known stability issues and may cause unexpected behavior. -

-

Are you sure you want to proceed?

-
-
- - Cancel - - Install Anyway - - -
-
-
+ {/* Unstable Version Confirmation Dialog */} + + + + + + Install Unstable Version? + + +

+ You are about to install v{pendingInstallVersion}, which is above + the maximum stable version{' '} + v{versionsData?.maxStableVersion || '6.6.80'}. +

+

+ This version has known stability issues and may cause unexpected behavior. +

+

Are you sure you want to proceed?

+
+
+ + Cancel + + Install Anyway + + +
+
+
+ ); } From d743e504899fe98bbb6170a73d43d81f9b9a247a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 19:31:17 +0000 Subject: [PATCH 19/30] chore(release): 7.13.1-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f9657cd7..ed0004b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.3", + "version": "7.13.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 4f69abbe88fb0bd6782373b5eeebd665dfcafd4b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 14:31:55 -0500 Subject: [PATCH 20/30] fix(ui): clean up ProxyStatusWidget layout spacing - Separate header row (title + icon buttons) from version row - Compact update badge: h-4, px-1.5, gap-0.5 - Slightly more margin between rows (mt-1.5) - Fix badge conditional rendering --- .../monitoring/proxy-status-widget.tsx | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index a67db32f..2417b6d6 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -272,7 +272,7 @@ export function ProxyStatusWidget() { isRunning ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/30' )} > - {/* Header row: Status dot, title, version, update badge, icon buttons */} + {/* Header row: Status dot, title, icon buttons */}
{/* Status indicator */} @@ -283,54 +283,14 @@ export function ProxyStatusWidget() { )} /> CLIProxy Plus - - {/* Version in header */} - {currentVersion && ( - - v{currentVersion} - - )} - - {/* Clickable Update/Downgrade badge */} - {(hasUpdate || isUnstable) && targetVersion && ( - handleInstallVersion(targetVersion)} - title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} - > - {isUnstable ? ( - <> - - {targetVersion} - - ) : ( - <> - - {targetVersion} - - )} - - )}
- {/* Right side: status icon + control buttons when running */} + {/* Right side: icon buttons when running */}
{isLoading ? ( ) : isRunning ? ( <> - {/* Icon buttons: Restart, Stop, Settings/Close */}
+ {/* Version row: version + update badge */} + {currentVersion && ( +
+ + v{currentVersion} + + {(hasUpdate || isUnstable) && targetVersion && ( + handleInstallVersion(targetVersion)} + title={`Click to ${isUnstable ? 'downgrade' : 'update'}`} + > + {isUnstable ? ( + + ) : ( + + )} + {targetVersion} + + )} +
+ )} + {/* Stats row when running */} {isRunning && status && (
From 4fd4d6c264b5dbcb9f3e181b46a82090764f46c6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 14:54:27 -0500 Subject: [PATCH 21/30] fix(ui): add missing isStable and maxStableVersion to type --- ui/src/lib/api-client.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 55f3519f..aae75b09 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -259,6 +259,9 @@ export interface CliproxyUpdateCheckResult { latestVersion: string; fromCache: boolean; checkedAt: number; // Unix timestamp of last check + isStable: boolean; // Whether current version is at or below max stable + maxStableVersion: string; // Maximum stable version (e.g., "6.6.80") + stabilityMessage?: string; // Warning message if running unstable version } /** Available versions list from GitHub releases */ From f2d9073b0d60e04839796d62e67ce3fe856e3ea0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 21:59:28 +0000 Subject: [PATCH 22/30] chore(release): 7.13.1-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ed0004b5..39e286db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.4", + "version": "7.13.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From f98bb24a98618df132857b414e30997eb3cf0b90 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 20:47:54 -0500 Subject: [PATCH 23/30] feat(cliproxy): add background token refresh worker Proactively refreshes OAuth tokens before expiry to prevent "context canceled" errors during long requests. - Add TokenRefreshWorker with configurable interval/retries - Add token-expiry-checker for multi-provider token inspection - Add provider-refreshers abstraction (Gemini implemented) - Integrate with service-manager lifecycle (start/stop) - Add config schema in unified-config-types - Include edge case protections: - expiry_date/refresh_token validation - File size limits (1MB) - Process exit handlers - Timeout wrapper - Config sanitization - Unrecoverable error detection --- src/cliproxy/auth/gemini-token-refresh.ts | 8 +- .../auth/provider-refreshers/index.ts | 69 +++++ src/cliproxy/auth/token-expiry-checker.ts | 131 ++++++++ src/cliproxy/auth/token-refresh-config.ts | 31 ++ src/cliproxy/auth/token-refresh-worker.ts | 280 ++++++++++++++++++ src/cliproxy/service-manager.ts | 77 +++++ src/config/unified-config-types.ts | 19 ++ 7 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 src/cliproxy/auth/provider-refreshers/index.ts create mode 100644 src/cliproxy/auth/token-expiry-checker.ts create mode 100644 src/cliproxy/auth/token-refresh-config.ts create mode 100644 src/cliproxy/auth/token-refresh-worker.ts diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index f6679c4c..fedb48dd 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -106,11 +106,12 @@ export function isGeminiTokenExpiringSoon(): boolean { /** * Refresh Gemini access token using refresh_token - * @returns true if refresh succeeded, false otherwise + * @returns Result with success status, optional error, and expiry time */ export async function refreshGeminiToken(): Promise<{ success: boolean; error?: string; + expiresAt?: number; }> { const creds = readGeminiCreds(); if (!creds || !creds.refresh_token) { @@ -151,17 +152,18 @@ export async function refreshGeminiToken(): Promise<{ } // Update credentials file with new token + const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; const updatedCreds: GeminiOAuthCreds = { ...creds, access_token: data.access_token, - expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000, + expiry_date: expiresAt, }; const writeError = writeGeminiCreds(updatedCreds); if (writeError) { return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; } - return { success: true }; + return { success: true, expiresAt }; } catch (err) { clearTimeout(timeoutId); if (err instanceof Error && err.name === 'AbortError') { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts new file mode 100644 index 00000000..232fe8c9 --- /dev/null +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -0,0 +1,69 @@ +/** + * Provider Token Refreshers + * + * Exports refresh functions for each OAuth provider. + * Currently only Gemini is implemented; others return placeholder errors. + */ + +import { CLIProxyProvider } from '../../types'; +import { refreshGeminiToken } from '../gemini-token-refresh'; + +/** Token refresh result */ +export interface ProviderRefreshResult { + success: boolean; + error?: string; + expiresAt?: number; +} + +/** + * Refresh token for a specific provider and account + * @param provider Provider to refresh + * @param _accountId Account ID (currently unused, multi-account not yet implemented) + * @returns Refresh result with success status and optional error + */ +export async function refreshToken( + provider: CLIProxyProvider, + _accountId: string +): Promise { + switch (provider) { + case 'gemini': + return await refreshGeminiTokenWrapper(); + + case 'codex': + case 'agy': + case 'qwen': + case 'iflow': + case 'kiro': + case 'ghcp': + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + + default: + return { + success: false, + error: `Unknown provider: ${provider}`, + }; + } +} + +/** + * Wrapper for Gemini token refresh + * Converts gemini-token-refresh.ts format to provider-refreshers format + */ +async function refreshGeminiTokenWrapper(): Promise { + const result = await refreshGeminiToken(); + + if (!result.success) { + return { + success: false, + error: result.error, + }; + } + + return { + success: true, + expiresAt: result.expiresAt, + }; +} diff --git a/src/cliproxy/auth/token-expiry-checker.ts b/src/cliproxy/auth/token-expiry-checker.ts new file mode 100644 index 00000000..d3833173 --- /dev/null +++ b/src/cliproxy/auth/token-expiry-checker.ts @@ -0,0 +1,131 @@ +/** + * Token Expiry Checker + * + * Inspects token files to determine expiry times and refresh requirements. + * Supports expiry_date field with fallback to file modification time. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { CLIProxyProvider } from '../types'; +import { getProviderAccounts, getAccountTokenPath } from '../account-manager'; + +/** Preemptive refresh time: refresh tokens 45 minutes before expiry */ +export const PREEMPTIVE_REFRESH_MINUTES = 45; + +/** Fallback expiry: assume 50 minutes if no expiry_date field */ +export const FALLBACK_EXPIRY_MINUTES = 50; + +/** Maximum token file size in bytes (1MB) - prevent DoS from huge files */ +const MAX_TOKEN_FILE_SIZE = 1024 * 1024; + +/** Token expiry information for a single account */ +export interface TokenExpiryInfo { + /** Provider name */ + provider: CLIProxyProvider; + /** Account ID */ + accountId: string; + /** Path to token file */ + tokenFile: string; + /** Expiry timestamp (Unix ms) */ + expiresAt: number; + /** Whether token needs refresh (within preemptive window) */ + needsRefresh: boolean; + /** Token file last modified time */ + lastModified: Date; +} + +/** + * Token file structure + */ +interface TokenData { + access_token?: string; + refresh_token?: string; + expiry_date?: number; // Unix timestamp ms + type?: string; +} + +/** + * Get token expiry info for a specific account + * @returns null if token file doesn't exist or is invalid + */ +export function getTokenExpiryInfo( + provider: CLIProxyProvider, + accountId: string +): TokenExpiryInfo | null { + const tokenPath = getAccountTokenPath(provider, accountId); + if (!tokenPath || !fs.existsSync(tokenPath)) { + return null; + } + + try { + const stats = fs.statSync(tokenPath); + + // Prevent DoS from huge token files + if (stats.size > MAX_TOKEN_FILE_SIZE) { + return null; + } + + const content = fs.readFileSync(tokenPath, 'utf-8'); + const data: TokenData = JSON.parse(content); + + // Validate refresh_token exists (required for refresh) + if (!data.refresh_token || typeof data.refresh_token !== 'string') { + return null; + } + + // Calculate expiry time with validation + let expiresAt: number; + if ( + data.expiry_date && + typeof data.expiry_date === 'number' && + Number.isFinite(data.expiry_date) && + data.expiry_date > 0 + ) { + // Use expiry_date field if valid + expiresAt = data.expiry_date; + } else { + // Fallback: use file mtime + 50 minutes + expiresAt = stats.mtime.getTime() + FALLBACK_EXPIRY_MINUTES * 60 * 1000; + } + + // Check if needs refresh (within preemptive window) + const now = Date.now(); + const timeUntilExpiry = expiresAt - now; + const preemptiveMs = PREEMPTIVE_REFRESH_MINUTES * 60 * 1000; + const needsRefresh = timeUntilExpiry < preemptiveMs; + + return { + provider, + accountId, + tokenFile: path.basename(tokenPath), + expiresAt, + needsRefresh, + lastModified: stats.mtime, + }; + } catch { + return null; + } +} + +/** + * Get token expiry info for all accounts across all providers + * @returns Array of token expiry info, excluding invalid tokens + */ +export function getAllTokenExpiryInfo(): TokenExpiryInfo[] { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; + const results: TokenExpiryInfo[] = []; + + for (const provider of providers) { + const accounts = getProviderAccounts(provider); + + for (const account of accounts) { + const info = getTokenExpiryInfo(provider, account.id); + if (info) { + results.push(info); + } + } + } + + return results; +} diff --git a/src/cliproxy/auth/token-refresh-config.ts b/src/cliproxy/auth/token-refresh-config.ts new file mode 100644 index 00000000..39b25f42 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-config.ts @@ -0,0 +1,31 @@ +/** + * Token Refresh Configuration + * + * Loads token refresh worker settings from unified config. + * Returns null if disabled or not configured. + */ + +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TokenRefreshSettings } from '../../config/unified-config-types'; + +/** + * Get token refresh configuration from unified config + * @returns Config if enabled, null if disabled or not configured + */ +export function getTokenRefreshConfig(): TokenRefreshSettings | null { + const config = loadOrCreateUnifiedConfig(); + + // Return null if not configured or explicitly disabled + if (!config.cliproxy?.token_refresh?.enabled) { + return null; + } + + // Return config with defaults + return { + enabled: true, + interval_minutes: config.cliproxy.token_refresh.interval_minutes ?? 30, + preemptive_minutes: config.cliproxy.token_refresh.preemptive_minutes ?? 45, + max_retries: config.cliproxy.token_refresh.max_retries ?? 3, + verbose: config.cliproxy.token_refresh.verbose ?? false, + }; +} diff --git a/src/cliproxy/auth/token-refresh-worker.ts b/src/cliproxy/auth/token-refresh-worker.ts new file mode 100644 index 00000000..071e63d9 --- /dev/null +++ b/src/cliproxy/auth/token-refresh-worker.ts @@ -0,0 +1,280 @@ +/** + * Token Refresh Worker + * + * Background worker that periodically checks and refreshes OAuth tokens + * before they expire. Runs as interval loop with retry logic. + */ + +import { CLIProxyProvider } from '../types'; +import { getAllTokenExpiryInfo, TokenExpiryInfo } from './token-expiry-checker'; +import { refreshToken } from './provider-refreshers'; + +/** Worker configuration */ +export interface TokenRefreshConfig { + /** Refresh check interval in minutes (default: 30) */ + refreshInterval: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptiveTime: number; + /** Maximum retry attempts per token (default: 3) */ + maxRetries: number; + /** Base delay for exponential backoff in ms (default: 1000) */ + retryBaseDelay: number; + /** Timeout for refresh operations in ms (default: 10000) */ + refreshTimeout: number; + /** Enable verbose logging */ + verbose: boolean; +} + +/** Result of a token refresh attempt */ +export interface RefreshResult { + provider: CLIProxyProvider; + accountId: string; + success: boolean; + error?: string; + refreshedAt?: Date; + nextExpiry?: number; +} + +/** Default worker configuration */ +const DEFAULT_CONFIG: TokenRefreshConfig = { + refreshInterval: 30, + preemptiveTime: 45, + maxRetries: 3, + retryBaseDelay: 1000, + refreshTimeout: 10000, + verbose: false, +}; + +/** Minimum config values to prevent infinite loops */ +const MIN_REFRESH_INTERVAL = 1; // 1 minute minimum +const MIN_RETRY_BASE_DELAY = 100; // 100ms minimum + +/** Unrecoverable error patterns - don't retry these */ +const UNRECOVERABLE_ERRORS = [ + 'No refresh token', + 'Invalid client', + 'Invalid grant', + 'Token has been revoked', + 'Token not found', +]; + +/** Validate and sanitize config values */ +function sanitizeConfig(config: TokenRefreshConfig): TokenRefreshConfig { + return { + refreshInterval: Math.max( + MIN_REFRESH_INTERVAL, + config.refreshInterval || DEFAULT_CONFIG.refreshInterval + ), + preemptiveTime: Math.max(0, config.preemptiveTime || DEFAULT_CONFIG.preemptiveTime), + maxRetries: Math.max(1, config.maxRetries || DEFAULT_CONFIG.maxRetries), + retryBaseDelay: Math.max( + MIN_RETRY_BASE_DELAY, + config.retryBaseDelay || DEFAULT_CONFIG.retryBaseDelay + ), + refreshTimeout: Math.max(1000, config.refreshTimeout || DEFAULT_CONFIG.refreshTimeout), + verbose: config.verbose ?? DEFAULT_CONFIG.verbose, + }; +} + +/** Promise with timeout */ +function withTimeout(promise: Promise, timeoutMs: number, errorMsg: string): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(errorMsg)), timeoutMs)), + ]); +} + +/** + * Background token refresh worker + * Manages periodic token refresh checks with retry logic + */ +export class TokenRefreshWorker { + private config: TokenRefreshConfig; + private intervalId: NodeJS.Timeout | null = null; + private running = false; + private lastResults: RefreshResult[] = []; + private exitHandler: (() => void) | null = null; + + constructor(config: Partial = {}) { + this.config = sanitizeConfig({ ...DEFAULT_CONFIG, ...config }); + } + + /** + * Start the worker + * Runs refresh loop immediately, then on interval + */ + start(): void { + if (this.running) { + return; + } + + this.running = true; + this.log('[i] Token refresh worker started'); + + // Register process exit handlers for cleanup + this.exitHandler = () => this.stop(); + process.on('SIGINT', this.exitHandler); + process.on('SIGTERM', this.exitHandler); + process.on('beforeExit', this.exitHandler); + + // Run immediately on start + void this.refreshLoop(); + + // Then run on interval + const intervalMs = this.config.refreshInterval * 60 * 1000; + this.intervalId = setInterval(() => { + void this.refreshLoop(); + }, intervalMs); + } + + /** + * Stop the worker + */ + stop(): void { + if (!this.running) { + return; + } + + this.running = false; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + // Remove process exit handlers + if (this.exitHandler) { + process.off('SIGINT', this.exitHandler); + process.off('SIGTERM', this.exitHandler); + process.off('beforeExit', this.exitHandler); + this.exitHandler = null; + } + + this.log('[i] Token refresh worker stopped'); + } + + /** + * Check if worker is active + */ + isActive(): boolean { + return this.running; + } + + /** + * Manually trigger refresh check now + */ + async refreshNow(): Promise { + return await this.refreshLoop(); + } + + /** + * Get results from last refresh cycle + */ + getLastRefreshResults(): RefreshResult[] { + return [...this.lastResults]; + } + + /** + * Main refresh loop + * Checks all tokens and refreshes those needing refresh + */ + private async refreshLoop(): Promise { + const results: RefreshResult[] = []; + + try { + const tokens = getAllTokenExpiryInfo(); + const tokensNeedingRefresh = tokens.filter((t) => t.needsRefresh); + + if (tokensNeedingRefresh.length === 0) { + this.log('[OK] All tokens valid, no refresh needed'); + this.lastResults = []; + return results; + } + + this.log(`[i] Refreshing ${tokensNeedingRefresh.length} token(s)...`); + + for (const token of tokensNeedingRefresh) { + const result = await this.refreshWithRetry(token); + results.push(result); + + if (result.success) { + this.log(`[OK] ${token.provider}/${token.accountId} refreshed`); + } else { + this.log(`[X] ${token.provider}/${token.accountId} failed: ${result.error}`); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.log(`[X] Refresh loop error: ${msg}`); + } + + this.lastResults = results; + return results; + } + + /** + * Refresh a single token with retry logic + * Uses exponential backoff on failures + */ + private async refreshWithRetry(token: TokenExpiryInfo): Promise { + let lastError = 'Unknown error'; + + for (let attempt = 0; attempt < this.config.maxRetries; attempt++) { + try { + // Apply timeout to refresh operation + const result = await withTimeout( + refreshToken(token.provider, token.accountId), + this.config.refreshTimeout, + `Refresh timeout after ${this.config.refreshTimeout}ms` + ); + + if (result.success) { + return { + provider: token.provider, + accountId: token.accountId, + success: true, + refreshedAt: new Date(), + nextExpiry: result.expiresAt, + }; + } + + lastError = result.error || 'Refresh failed'; + + // Don't retry if error indicates unrecoverable issue + if (this.isUnrecoverableError(lastError)) { + break; + } + } catch (error) { + lastError = error instanceof Error ? error.message : 'Unknown error'; + } + + // Exponential backoff before retry + if (attempt < this.config.maxRetries - 1) { + const delay = this.config.retryBaseDelay * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return { + provider: token.provider, + accountId: token.accountId, + success: false, + error: lastError, + }; + } + + /** + * Check if error is unrecoverable (should not retry) + */ + private isUnrecoverableError(error: string): boolean { + return UNRECOVERABLE_ERRORS.some((pattern) => error.includes(pattern)); + } + + /** + * Log message if verbose enabled + */ + private log(msg: string): void { + if (this.config.verbose) { + console.error(`[token-refresh] ${msg}`); + } + } +} diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 5befc836..6788c66c 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -25,10 +25,15 @@ import { registerSession } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { isCliproxyRunning } from './stats-fetcher'; +import { TokenRefreshWorker, type RefreshResult } from './auth/token-refresh-worker'; +import { getTokenRefreshConfig } from './auth/token-refresh-config'; /** Background proxy process reference */ let proxyProcess: ChildProcess | null = null; +/** Token refresh worker instance */ +let tokenRefreshWorker: TokenRefreshWorker | null = null; + /** Cleanup registered flag */ let cleanupRegistered = false; @@ -77,6 +82,12 @@ function registerCleanup(): void { if (cleanupRegistered) return; const cleanup = () => { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -90,6 +101,38 @@ function registerCleanup(): void { cleanupRegistered = true; } +/** + * Start token refresh worker if configured + * @param verbose Enable verbose logging + */ +function startTokenRefreshWorker(verbose: boolean): void { + // Skip if already running + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + return; + } + + // Load config + const config = getTokenRefreshConfig(); + if (!config) { + // Not configured or disabled + return; + } + + // Create and start worker + tokenRefreshWorker = new TokenRefreshWorker({ + refreshInterval: config.interval_minutes ?? 30, + preemptiveTime: config.preemptive_minutes ?? 45, + maxRetries: config.max_retries ?? 3, + verbose: config.verbose || verbose, + }); + + tokenRefreshWorker.start(); + + if (verbose) { + console.error('[i] Token refresh worker started'); + } +} + export interface ServiceStartResult { started: boolean; alreadyRunning: boolean; @@ -254,6 +297,9 @@ export async function ensureCliproxyService( log(`Session registered for PID ${proxyProcess.pid}`); } + // 6. Start token refresh worker if configured + startTokenRefreshWorker(verbose); + return { started: true, alreadyRunning: false, port }; }); } @@ -262,6 +308,13 @@ export async function ensureCliproxyService( * Stop the managed CLIProxy service */ export function stopCliproxyService(): boolean { + // Stop token refresh worker first + if (tokenRefreshWorker && tokenRefreshWorker.isActive()) { + tokenRefreshWorker.stop(); + tokenRefreshWorker = null; + } + + // Then stop proxy process if (proxyProcess && !proxyProcess.killed) { proxyProcess.kill('SIGTERM'); proxyProcess = null; @@ -283,3 +336,27 @@ export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Pr return { running, managedByUs, port }; } + +/** + * Check if token refresh worker is running + */ +export function isTokenRefreshWorkerRunning(): boolean { + return tokenRefreshWorker !== null && tokenRefreshWorker.isActive(); +} + +/** + * Get token refresh worker status + */ +export function getTokenRefreshStatus(): { + running: boolean; + lastResults: RefreshResult[] | null; +} { + if (!tokenRefreshWorker) { + return { running: false, lastResults: null }; + } + + return { + running: tokenRefreshWorker.isActive(), + lastResults: tokenRefreshWorker.getLastRefreshResults(), + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 166349cb..6a20a4d4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -93,6 +93,23 @@ export interface CLIProxyLoggingConfig { request_log?: boolean; } +/** + * Token refresh configuration. + * Manages background token refresh worker settings. + */ +export interface TokenRefreshSettings { + /** Enable background token refresh (default: false) */ + enabled?: boolean; + /** Refresh check interval in minutes (default: 30) */ + interval_minutes?: number; + /** Preemptive refresh time in minutes (default: 45) */ + preemptive_minutes?: number; + /** Maximum retry attempts per token (default: 3) */ + max_retries?: number; + /** Enable verbose logging (default: false) */ + verbose?: boolean; +} + /** * CLIProxy configuration section. */ @@ -109,6 +126,8 @@ export interface CLIProxyConfig { kiro_no_incognito?: boolean; /** Global auth configuration for CLIProxyAPI */ auth?: CLIProxyAuthConfig; + /** Background token refresh worker settings */ + token_refresh?: TokenRefreshSettings; } /** From 1067afbea713625713e72c1738ef06a21bd04d62 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 20:48:49 -0500 Subject: [PATCH 24/30] fix(shared-manager): normalize plugin registry paths to canonical ~/.claude/ Replaces instance-specific paths (/.ccs/instances//) with canonical /.claude/ paths in installed_plugins.json after linking. Fixes #276 --- src/management/shared-manager.ts | 38 +++++++++++ tests/unit/shared-manager.test.ts | 104 ++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/unit/shared-manager.test.ts diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 7edfcddc..65ea5ed1 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -194,6 +194,44 @@ class SharedManager { } } } + + // Normalize plugin registry paths after linking + this.normalizePluginRegistryPaths(); + } + + /** + * Normalize plugin registry paths to use canonical ~/.claude/ paths + * instead of instance-specific ~/.ccs/instances// paths. + * + * This ensures installed_plugins.json is consistent regardless of + * which CCS instance installed the plugin. + */ + normalizePluginRegistryPaths(): void { + const registryPath = path.join(this.claudeDir, 'plugins', 'installed_plugins.json'); + + // Skip if registry doesn't exist + if (!fs.existsSync(registryPath)) { + return; + } + + try { + const original = fs.readFileSync(registryPath, 'utf8'); + + // Replace instance paths with canonical claude path + // Pattern: /.ccs/instances// -> /.claude/ + const normalized = original.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); + + // Only write if changes were made + if (normalized !== original) { + // Validate JSON before writing + JSON.parse(normalized); + fs.writeFileSync(registryPath, normalized, 'utf8'); + console.log(ok('Normalized plugin registry paths')); + } + } catch (err) { + // Log warning but don't fail - registry may be malformed + console.log(warn(`Could not normalize plugin registry: ${(err as Error).message}`)); + } } /** diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts new file mode 100644 index 00000000..f9124a58 --- /dev/null +++ b/tests/unit/shared-manager.test.ts @@ -0,0 +1,104 @@ +/** + * Unit tests for SharedManager - plugin registry path normalization + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Test the normalization regex pattern directly +const normalizePluginPaths = (content: string): string => { + return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/'); +}; + +describe('SharedManager', () => { + describe('normalizePluginRegistryPaths', () => { + describe('regex pattern', () => { + it('should replace instance paths with canonical claude path', () => { + const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; + const expected = '/home/user/.claude/plugins/cache/plugin/0.0.2'; + expect(normalizePluginPaths(input)).toBe(expected); + }); + + it('should handle different instance names', () => { + const inputs = [ + '/home/user/.ccs/instances/work/plugins/cache/plugin/1.0.0', + '/home/user/.ccs/instances/personal/plugins/cache/plugin/1.0.0', + '/home/user/.ccs/instances/test-account/plugins/cache/plugin/1.0.0', + ]; + for (const input of inputs) { + expect(normalizePluginPaths(input)).toContain('/.claude/'); + expect(normalizePluginPaths(input)).not.toContain('/.ccs/instances/'); + } + }); + + it('should handle multiple occurrences', () => { + const input = JSON.stringify({ + plugins: { + 'plugin-a': [{ installPath: '/home/user/.ccs/instances/ck/plugins/a' }], + 'plugin-b': [{ installPath: '/home/user/.ccs/instances/work/plugins/b' }], + }, + }); + const result = normalizePluginPaths(input); + expect(result).not.toContain('/.ccs/instances/'); + expect(result.match(/\.claude/g)?.length).toBe(2); + }); + + it('should not modify already-canonical paths', () => { + const input = '/home/user/.claude/plugins/cache/plugin/0.0.2'; + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should be idempotent', () => { + const input = '/home/user/.ccs/instances/ck/plugins/cache/plugin/0.0.2'; + const first = normalizePluginPaths(input); + const second = normalizePluginPaths(first); + expect(first).toBe(second); + }); + + it('should preserve JSON structure', () => { + const original = { + version: 2, + plugins: { + 'claude-hud@claude-hud': [ + { + scope: 'user', + installPath: '/home/kai/.ccs/instances/ck/plugins/cache/claude-hud/claude-hud/0.0.2', + version: '0.0.2', + }, + ], + }, + }; + const input = JSON.stringify(original, null, 2); + const result = normalizePluginPaths(input); + + // Should be valid JSON + expect(() => JSON.parse(result)).not.toThrow(); + + // Should have normalized path + const parsed = JSON.parse(result); + expect(parsed.plugins['claude-hud@claude-hud'][0].installPath).toBe( + '/home/kai/.claude/plugins/cache/claude-hud/claude-hud/0.0.2' + ); + }); + }); + + describe('edge cases', () => { + it('should handle empty object', () => { + const input = JSON.stringify({}); + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should handle plugins without installPath', () => { + const input = JSON.stringify({ plugins: {} }); + expect(normalizePluginPaths(input)).toBe(input); + }); + + it('should handle Windows-style paths (backslash)', () => { + // Windows paths use backslashes, regex should not match + const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache'; + expect(normalizePluginPaths(input)).toBe(input); + }); + }); + }); +}); From cfced9bface497822256c5ea7f792a43f3143a35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 15:04:19 +0000 Subject: [PATCH 25/30] chore(release): 7.13.1-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39e286db..aed85a9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.5", + "version": "7.13.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From bc5bce4256a64718f4745736056d0deceb7cc2ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 15:15:47 +0000 Subject: [PATCH 26/30] chore(release): 7.13.1-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aed85a9e..a3ca08ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.6", + "version": "7.13.1-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From cfe604a97c5ef79fbfb1f020579e0b5541d49b27 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 10:30:12 -0500 Subject: [PATCH 27/30] fix(cliproxy): add missing OAuth callback ports for codex, agy, iflow Port cleanup before OAuth was skipped for providers whose ports were only in comments. This caused hanging when stale processes blocked the callback port from previous auth attempts. Changes: - auth-types.ts: Add codex (1455), agy (51121), iflow (11451) to map - oauth-port-diagnostics.ts: Update iflow from device_code to auth_code flow - Add Claude (54545) to doc comments for future reference --- src/cliproxy/auth/auth-types.ts | 13 ++++++++----- src/management/oauth-port-diagnostics.ts | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 927c2e2a..42e3cc9e 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -15,17 +15,20 @@ import { AccountInfo } from '../account-manager'; * - Gemini: Authorization Code Flow with local callback server on port 8085 * - Codex: Authorization Code Flow with local callback server on port 1455 * - Agy: Authorization Code Flow with local callback server on port 51121 - * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - Kiro: Authorization Code Flow with local callback server on port 9876 + * - iFlow: Authorization Code Flow with local callback server on port 11451 + * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) + * - Qwen: Device Code Flow (polling-based, NO callback port needed) * - GHCP: Device Code Flow (polling-based, NO callback port needed) */ export const OAUTH_CALLBACK_PORTS: Partial> = { gemini: 8085, kiro: 9876, - // codex uses 1455 - // agy uses 51121 - // qwen uses Device Code Flow - no callback port needed - // ghcp uses Device Code Flow - no callback port needed + codex: 1455, + agy: 51121, + iflow: 11451, + // qwen: Device Code Flow - no callback port + // ghcp: Device Code Flow - no callback port }; /** diff --git a/src/management/oauth-port-diagnostics.ts b/src/management/oauth-port-diagnostics.ts index e3d28317..262d963d 100644 --- a/src/management/oauth-port-diagnostics.ts +++ b/src/management/oauth-port-diagnostics.ts @@ -31,7 +31,7 @@ export const OAUTH_CALLBACK_PORTS: Record = { codex: 1455, agy: 51121, qwen: null, // Device Code Flow - no callback port - iflow: null, // Device Code Flow - no callback port + iflow: 11451, // Authorization Code Flow kiro: 9876, // Authorization Code Flow ghcp: null, // Device Code Flow - no callback port }; @@ -49,7 +49,7 @@ export const OAUTH_FLOW_TYPES: Record = { codex: 'authorization_code', agy: 'authorization_code', qwen: 'device_code', - iflow: 'device_code', + iflow: 'authorization_code', kiro: 'authorization_code', ghcp: 'device_code', }; From 0557f93f2fdb17972324f05c9e216785f893ad16 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 10:49:51 -0500 Subject: [PATCH 28/30] fix(oauth): add stdin keepalive to prevent blocking on manual URL prompt CLIProxyAPIPlus has a 15-second timer that prompts for manual URL paste. If user completes browser auth after this timer fires but before the non-blocking check, the prompt blocks forever on stdin since CCS pipes stdin but doesn't write to it. Workaround: Send newline every 16s for authorization code flows to skip the manual prompt and continue polling for callback. --- src/cliproxy/auth/oauth-process.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index c09a9536..2eb1cbdb 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -347,6 +347,21 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + if (!isDeviceCodeFlow && stdinMode === 'pipe') { + stdinKeepalive = setInterval(() => { + if (authProcess.stdin && !authProcess.stdin.destroyed) { + authProcess.stdin.write('\n'); + log('Sent stdin keepalive (skip manual URL prompt)'); + } + }, 16000); + } + authProcess.stdout?.on('data', async (data: Buffer) => { await handleStdout(data.toString(), state, options, authProcess, log); }); @@ -393,6 +408,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers before killing process process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); @@ -409,6 +426,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); @@ -462,6 +481,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { clearTimeout(timeout); + // H7: Clear stdin keepalive interval + if (stdinKeepalive) clearInterval(stdinKeepalive); // H5: Remove signal handlers to prevent memory leaks process.removeListener('SIGINT', cleanup); process.removeListener('SIGTERM', cleanup); From 472497fb0324a92993b2a7e7fd27c8f071a9e7c6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 6 Jan 2026 11:31:40 -0500 Subject: [PATCH 29/30] fix(oauth): harden cleanup for edge cases in auth process - Clear stdinKeepalive interval on SIGINT/SIGTERM signal handlers - Add cancelProjectSelection() to timeout/exit/error handlers - Close server on error path in testLocalhostBinding to prevent fd leak - Add TTL-based cleanup for stale auth sessions (10 min expiry) - Use DEVICE_CODE_TIMEOUT_MS constant instead of hardcoded value (DRY) --- src/cliproxy/auth-session-manager.ts | 30 ++++++++++++++++++++++++++++ src/cliproxy/auth/oauth-process.ts | 18 ++++++++++++++--- src/utils/port-utils.ts | 17 +++++++++------- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/cliproxy/auth-session-manager.ts b/src/cliproxy/auth-session-manager.ts index ce718be9..0ad0dd7c 100644 --- a/src/cliproxy/auth-session-manager.ts +++ b/src/cliproxy/auth-session-manager.ts @@ -8,6 +8,9 @@ import { EventEmitter } from 'events'; import { ChildProcess } from 'child_process'; +// H8: TTL for stale session cleanup (10 minutes - generous for OAuth flows) +const SESSION_TTL_MS = 10 * 60 * 1000; + export interface ActiveAuthSession { sessionId: string; provider: string; @@ -19,6 +22,31 @@ export const authSessionEvents = new EventEmitter(); const activeSessions = new Map(); +// H8: Periodic cleanup of stale sessions (prevents memory leak from orphaned sessions) +let cleanupInterval: ReturnType | null = null; + +function startCleanupInterval(): void { + if (cleanupInterval) return; + cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of activeSessions.entries()) { + if (now - session.startedAt > SESSION_TTL_MS) { + // Stale session - kill process if still running, then remove + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + activeSessions.delete(sessionId); + authSessionEvents.emit('session:expired', sessionId); + } + } + // Stop interval if no active sessions + if (activeSessions.size === 0 && cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } + }, 60000); // Check every minute +} + /** * Register an active OAuth session */ @@ -33,6 +61,8 @@ export function registerAuthSession( startedAt: Date.now(), process, }); + // H8: Start TTL cleanup when first session registered + startCleanupInterval(); authSessionEvents.emit('session:started', sessionId, provider); } diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 2eb1cbdb..6b5cb997 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -17,13 +17,18 @@ import { isProjectList, generateSessionId, requestProjectSelection, + cancelProjectSelection, type GCloudProject, type ProjectSelectionPrompt, } from '../project-selection-handler'; import { ProviderOAuthConfig } from './auth-types'; import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; -import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; +import { + deviceCodeEvents, + DEVICE_CODE_TIMEOUT_MS, + type DeviceCodePrompt, +} from '../device-code-handler'; import { OAUTH_FLOW_TYPES } from '../../management'; import { registerAuthSession, @@ -150,7 +155,7 @@ async function handleStdout( provider: options.provider, userCode: state.userCode, verificationUrl, - expiresAt: Date.now() + 900000, // 15 minutes + expiresAt: Date.now() + DEVICE_CODE_TIMEOUT_MS, }; deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt); @@ -311,8 +316,13 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; + // H5: Signal handling - properly kill child process on SIGINT/SIGTERM + // H8: Also clear stdinKeepalive interval to prevent memory leak const cleanup = () => { + if (stdinKeepalive) clearInterval(stdinKeepalive); if (authProcess && !authProcess.killed) { authProcess.kill('SIGTERM'); } @@ -352,7 +362,6 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise | null = null; if (!isDeviceCodeFlow && stdinMode === 'pipe') { stdinKeepalive = setInterval(() => { if (authProcess.stdin && !authProcess.stdin.destroyed) { @@ -415,6 +424,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { - if (err.code === 'EADDRINUSE') { - resolve({ success: false, message: `Port ${port} is already in use` }); - } else if (err.code === 'EACCES') { - resolve({ success: false, message: `Permission denied for port ${port}` }); - } else { - resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); - } + // H8: Close server to prevent fd leak on error path + server.close(() => { + if (err.code === 'EADDRINUSE') { + resolve({ success: false, message: `Port ${port} is already in use` }); + } else if (err.code === 'EACCES') { + resolve({ success: false, message: `Permission denied for port ${port}` }); + } else { + resolve({ success: false, message: `Cannot bind to port ${port}: ${err.message}` }); + } + }); }); server.once('listening', () => { From b44ccc97ea6c81cce5dc50c9bae99daf5565bbbf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 6 Jan 2026 16:33:53 +0000 Subject: [PATCH 30/30] chore(release): 7.13.1-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3ca08ce..50656b37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.1-dev.7", + "version": "7.13.1-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",