diff --git a/VERSION b/VERSION index 09b254e9..d3710c34 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.0.0 +6.0.0-dev.2 diff --git a/package.json b/package.json index 930fa101..edecc87e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.0.0", + "version": "6.0.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 80f19ea4..3b69a2f6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -45,6 +45,13 @@ import { installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; +import { + getExistingProxy, + registerSession, + unregisterSession, + hasActiveSessions, + cleanupOrphanedSessions, +} from './session-tracker'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -303,85 +310,116 @@ export async function execClaudeWithCLIProxy( const configPath = generateConfig(provider, cfg.port); log(`Config written: ${configPath}`); - // 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy) - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // Zombie CLIProxy from previous run - auto-kill it - log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); - const killed = killProcessOnPort(cfg.port, verbose); - if (killed) { - console.log(info(`Cleaned up zombie CLIProxy process`)); - // Wait a bit for port to be released - await new Promise((r) => setTimeout(r, 500)); + // 6a. Pre-flight check: handle existing proxy or port conflicts + // Clean up orphaned sessions first (from crashed proxies) + cleanupOrphanedSessions(cfg.port); + + // Check if there's an existing healthy proxy we can reuse + const existingProxy = getExistingProxy(cfg.port); + let proxy: ChildProcess | null = null; + let sessionId: string; + let isReusingProxy = false; + + if (existingProxy) { + // Reuse existing proxy - another CCS session started it + log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); + sessionId = registerSession(cfg.port, existingProxy.pid); + isReusingProxy = true; + console.log( + info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) + ); + } else { + // No existing proxy - check if port is free + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // CLIProxy on port but no session lock - likely orphaned/zombie + // Only kill if no active sessions registered + if (!hasActiveSessions()) { + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Active sessions exist but getExistingProxy returned null - something's wrong + // Try to connect anyway + log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); + } + } else { + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); } - } else { - // Non-CLIProxy process blocking the port - warn user - console.error(''); - console.error( - warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); } - } - // 6b. Spawn CLIProxyAPI binary - const proxyArgs = ['--config', configPath]; + // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + const proxyArgs = ['--config', configPath]; - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - const proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], - detached: false, - }); - - // Forward proxy output in verbose mode - if (verbose) { - proxy.stdout?.on('data', (data: Buffer) => { - process.stderr.write(`[cliproxy-out] ${data.toString()}`); + proxy = spawn(binaryPath, proxyArgs, { + stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], + detached: false, }); - proxy.stderr?.on('data', (data: Buffer) => { - process.stderr.write(`[cliproxy-err] ${data.toString()}`); + + // Forward proxy output in verbose mode + if (verbose) { + proxy.stdout?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-out] ${data.toString()}`); + }); + proxy.stderr?.on('data', (data: Buffer) => { + process.stderr.write(`[cliproxy-err] ${data.toString()}`); + }); + } + + // Handle proxy errors + proxy.on('error', (error) => { + console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); - } - // Handle proxy errors - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); + // 7. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - readySpinner.fail('CLIProxy startup failed'); - proxy.kill('SIGTERM'); + const err = error as Error; + console.error(''); + console.error(fail('CLIProxy failed to start')); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${cfg.port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); + console.error(''); - const err = error as Error; - console.error(''); - console.error(fail('CLIProxy failed to start')); - console.error(''); - console.error('Possible causes:'); - console.error(` 1. Port ${cfg.port} already in use`); - console.error(' 2. Binary crashed on startup'); - console.error(' 3. Invalid configuration'); - console.error(''); - console.error('Troubleshooting:'); - console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); - console.error(' - Run with --verbose for detailed logs'); - console.error(` - View config: ${getCatCommand(configPath)}`); - console.error(' - Try: ccs doctor --fix'); - console.error(''); + throw new Error(`CLIProxy startup failed: ${err.message}`); + } - throw new Error(`CLIProxy startup failed: ${err.message}`); + // Register this session with the new proxy + sessionId = registerSession(cfg.port, proxy.pid as number); + log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } // 7. Execute Claude CLI with proxied environment @@ -435,10 +473,25 @@ export async function execClaudeWithCLIProxy( }); } - // 8. Cleanup: kill proxy when Claude exits + // 8. Cleanup: unregister session when Claude exits, kill proxy only if last session claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - proxy.kill('SIGTERM'); + + // Unregister this session - returns true if we were the last session + const shouldKillProxy = unregisterSession(sessionId); + log(`Session ${sessionId} unregistered, shouldKillProxy=${shouldKillProxy}`); + + if (shouldKillProxy && proxy) { + // We were the last session and we own the proxy - kill it + log('Last session, killing proxy'); + proxy.kill('SIGTERM'); + } else if (shouldKillProxy && isReusingProxy) { + // We were the last session but don't own the proxy process + // The proxy will be cleaned up as zombie on next session start + log('Last session but reusing proxy, proxy will be cleaned up later'); + } else { + log(`Other sessions still active, keeping proxy running`); + } if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -449,27 +502,39 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); - proxy.kill('SIGTERM'); + + // Unregister and conditionally kill proxy + const shouldKillProxy = unregisterSession(sessionId); + if (shouldKillProxy && proxy) { + proxy.kill('SIGTERM'); + } process.exit(1); }); // Handle parent process termination (SIGTERM, SIGINT) const cleanup = () => { log('Parent signal received, cleaning up'); - proxy.kill('SIGTERM'); + + // Unregister and conditionally kill proxy + const shouldKillProxy = unregisterSession(sessionId); + if (shouldKillProxy && proxy) { + proxy.kill('SIGTERM'); + } claude.kill('SIGTERM'); }; process.once('SIGTERM', cleanup); process.once('SIGINT', cleanup); - // Handle proxy crash - proxy.on('exit', (code, signal) => { - if (code !== 0 && code !== null) { - log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`); - // Don't kill Claude - it may have already exited - } - }); + // Handle proxy crash (only if we own the proxy) + if (proxy) { + proxy.on('exit', (code, signal) => { + if (code !== 0 && code !== null) { + log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`); + // Don't kill Claude - it may have already exited + } + }); + } } /** diff --git a/src/cliproxy/session-tracker.ts b/src/cliproxy/session-tracker.ts new file mode 100644 index 00000000..0f1697c8 --- /dev/null +++ b/src/cliproxy/session-tracker.ts @@ -0,0 +1,226 @@ +/** + * Session Tracker for CLIProxy Multi-Instance Support + * + * Manages reference counting for shared CLIProxy instances. + * Multiple CCS sessions can share a single proxy on the same port. + * Proxy only terminates when ALL sessions exit (count reaches 0). + * + * Lock file format: ~/.ccs/cliproxy/sessions.json + * { + * "port": 8317, + * "pid": 12345, // CLIProxy process PID + * "sessions": ["abc123", "def456"], // Active session IDs + * "startedAt": "2024-01-01T00:00:00Z" + * } + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { getCliproxyDir } from './config-generator'; + +/** Session lock file structure */ +interface SessionLock { + port: number; + pid: number; + sessions: string[]; + startedAt: string; +} + +/** Generate unique session ID */ +function generateSessionId(): string { + return crypto.randomBytes(8).toString('hex'); +} + +/** Get path to session lock file */ +function getSessionLockPath(): string { + return path.join(getCliproxyDir(), 'sessions.json'); +} + +/** Read session lock file (returns null if not exists or invalid) */ +function readSessionLock(): SessionLock | null { + const lockPath = getSessionLockPath(); + try { + if (!fs.existsSync(lockPath)) { + return null; + } + const content = fs.readFileSync(lockPath, 'utf-8'); + const lock = JSON.parse(content) as SessionLock; + // Validate structure + if ( + typeof lock.port !== 'number' || + typeof lock.pid !== 'number' || + !Array.isArray(lock.sessions) + ) { + return null; + } + return lock; + } catch { + return null; + } +} + +/** Write session lock file */ +function writeSessionLock(lock: SessionLock): void { + const lockPath = getSessionLockPath(); + const dir = path.dirname(lockPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 }); +} + +/** Delete session lock file */ +function deleteSessionLock(): void { + const lockPath = getSessionLockPath(); + try { + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } catch { + // Ignore errors on cleanup + } +} + +/** Check if a PID is still running */ +function isProcessRunning(pid: number): boolean { + try { + // Sending signal 0 checks if process exists without killing it + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Check if there's an existing proxy running that we can reuse. + * Returns the existing lock if proxy is healthy, null otherwise. + */ +export function getExistingProxy(port: number): SessionLock | null { + const lock = readSessionLock(); + if (!lock) { + return null; + } + + // Verify port matches + if (lock.port !== port) { + return null; + } + + // Verify proxy process is still running + if (!isProcessRunning(lock.pid)) { + // Proxy crashed - clean up stale lock + deleteSessionLock(); + return null; + } + + return lock; +} + +/** + * Register a new session with the proxy. + * Call this when starting a new CCS session that will use an existing proxy. + * @returns Session ID for this session + */ +export function registerSession(port: number, proxyPid: number): string { + const sessionId = generateSessionId(); + const existingLock = readSessionLock(); + + if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) { + // Add to existing sessions + existingLock.sessions.push(sessionId); + writeSessionLock(existingLock); + } else { + // Create new lock (first session for this proxy) + const newLock: SessionLock = { + port, + pid: proxyPid, + sessions: [sessionId], + startedAt: new Date().toISOString(), + }; + writeSessionLock(newLock); + } + + return sessionId; +} + +/** + * Unregister a session from the proxy. + * @returns true if this was the last session (proxy should be killed) + */ +export function unregisterSession(sessionId: string): boolean { + const lock = readSessionLock(); + if (!lock) { + // No lock file - assume we're the only session + return true; + } + + // Remove this session from the list + const index = lock.sessions.indexOf(sessionId); + if (index !== -1) { + lock.sessions.splice(index, 1); + } + + // Check if any sessions remain + if (lock.sessions.length === 0) { + // Last session - clean up lock file + deleteSessionLock(); + return true; + } + + // Other sessions still active - keep proxy running + writeSessionLock(lock); + return false; +} + +/** + * Get current session count for the proxy. + */ +export function getSessionCount(): number { + const lock = readSessionLock(); + if (!lock) { + return 0; + } + return lock.sessions.length; +} + +/** + * Check if proxy has any active sessions. + * Used to determine if a "zombie" proxy should be killed. + */ +export function hasActiveSessions(): boolean { + const lock = readSessionLock(); + if (!lock) { + return false; + } + + // Verify proxy is still running + if (!isProcessRunning(lock.pid)) { + deleteSessionLock(); + return false; + } + + return lock.sessions.length > 0; +} + +/** + * Clean up orphaned sessions (when proxy crashes). + * Called on startup to ensure clean state. + */ +export function cleanupOrphanedSessions(port: number): void { + const lock = readSessionLock(); + if (!lock) { + return; + } + + // If port doesn't match, this lock is for a different proxy + if (lock.port !== port) { + return; + } + + // If proxy is dead, clean up lock + if (!isProcessRunning(lock.pid)) { + deleteSessionLock(); + } +} diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js new file mode 100644 index 00000000..6625885c --- /dev/null +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -0,0 +1,330 @@ +/** + * Session Tracker Tests + * + * Tests for multi-instance CLIProxy session management. + * Verifies reference counting and cleanup behavior. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Set test isolation environment before importing +const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); +process.env.CCS_HOME = testHome; + +const { + getExistingProxy, + registerSession, + unregisterSession, + getSessionCount, + hasActiveSessions, + cleanupOrphanedSessions, +} = require('../../../dist/cliproxy/session-tracker'); + +describe('Session Tracker', function () { + const testPort = 18317; + let sessionLockPath; + + beforeEach(function () { + // Create test directories + const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + sessionLockPath = path.join(cliproxyDir, 'sessions.json'); + + // Clean up any existing lock file + if (fs.existsSync(sessionLockPath)) { + fs.unlinkSync(sessionLockPath); + } + }); + + afterEach(function () { + // Clean up lock file + if (fs.existsSync(sessionLockPath)) { + fs.unlinkSync(sessionLockPath); + } + }); + + afterAll(function () { + // Clean up test directory + try { + fs.rmSync(testHome, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + delete process.env.CCS_HOME; + }); + + describe('getExistingProxy', function () { + it('should return null when no lock file exists', function () { + const result = getExistingProxy(testPort); + assert.strictEqual(result, null); + }); + + it('should return null when port does not match', function () { + // Create lock with different port + const lock = { + port: 9999, + pid: process.pid, // Use current process as it's definitely running + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + const result = getExistingProxy(testPort); + assert.strictEqual(result, null); + }); + + it('should return lock when proxy is healthy', function () { + // Create lock with current process (guaranteed to be running) + const lock = { + port: testPort, + pid: process.pid, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + const result = getExistingProxy(testPort); + assert.notStrictEqual(result, null); + assert.strictEqual(result.port, testPort); + assert.strictEqual(result.pid, process.pid); + assert.deepStrictEqual(result.sessions, ['session1']); + }); + + it('should return null and cleanup when proxy is dead', function () { + // Create lock with non-existent PID + const lock = { + port: testPort, + pid: 999999999, // Very unlikely to exist + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + const result = getExistingProxy(testPort); + assert.strictEqual(result, null); + + // Lock file should be cleaned up + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + }); + + describe('registerSession', function () { + it('should create new lock file for first session', function () { + const sessionId = registerSession(testPort, process.pid); + + assert.ok(sessionId, 'should return session ID'); + assert.ok(fs.existsSync(sessionLockPath), 'should create lock file'); + + const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(lock.port, testPort); + assert.strictEqual(lock.pid, process.pid); + assert.strictEqual(lock.sessions.length, 1); + assert.strictEqual(lock.sessions[0], sessionId); + }); + + it('should add session to existing lock', function () { + // Register first session + const session1 = registerSession(testPort, process.pid); + + // Register second session + const session2 = registerSession(testPort, process.pid); + + assert.notStrictEqual(session1, session2, 'should generate unique IDs'); + + const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(lock.sessions.length, 2); + assert.ok(lock.sessions.includes(session1)); + assert.ok(lock.sessions.includes(session2)); + }); + + it('should create new lock if PID differs', function () { + // Create lock with different PID + const oldLock = { + port: testPort, + pid: 12345, + sessions: ['old-session'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(oldLock)); + + // Register with new PID + const sessionId = registerSession(testPort, process.pid); + + const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(lock.pid, process.pid); + assert.strictEqual(lock.sessions.length, 1); + assert.strictEqual(lock.sessions[0], sessionId); + }); + }); + + describe('unregisterSession', function () { + it('should return true when no lock exists', function () { + const result = unregisterSession('nonexistent'); + assert.strictEqual(result, true); + }); + + it('should remove session and return false when others remain', function () { + // Register two sessions + const session1 = registerSession(testPort, process.pid); + const session2 = registerSession(testPort, process.pid); + + // Unregister first + const shouldKill = unregisterSession(session1); + + assert.strictEqual(shouldKill, false, 'should not kill - other sessions active'); + + const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(lock.sessions.length, 1); + assert.strictEqual(lock.sessions[0], session2); + }); + + it('should return true and delete lock when last session', function () { + // Register single session + const session1 = registerSession(testPort, process.pid); + + // Unregister it + const shouldKill = unregisterSession(session1); + + assert.strictEqual(shouldKill, true, 'should kill - last session'); + assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file'); + }); + + it('should handle unregistering non-existent session gracefully', function () { + // Register a session + registerSession(testPort, process.pid); + + // Try to unregister wrong session + const shouldKill = unregisterSession('wrong-session-id'); + + // Should return false since a session still exists + assert.strictEqual(shouldKill, false); + }); + }); + + describe('getSessionCount', function () { + it('should return 0 when no lock exists', function () { + assert.strictEqual(getSessionCount(), 0); + }); + + it('should return correct count', function () { + registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 1); + + registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 2); + + registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 3); + }); + }); + + describe('hasActiveSessions', function () { + it('should return false when no lock exists', function () { + assert.strictEqual(hasActiveSessions(), false); + }); + + it('should return true when sessions exist and proxy running', function () { + registerSession(testPort, process.pid); + assert.strictEqual(hasActiveSessions(), true); + }); + + it('should return false and cleanup when proxy is dead', function () { + // Create lock with dead PID + const lock = { + port: testPort, + pid: 999999999, + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + assert.strictEqual(hasActiveSessions(), false); + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + }); + + describe('cleanupOrphanedSessions', function () { + it('should do nothing when no lock exists', function () { + cleanupOrphanedSessions(testPort); + // Should not throw + }); + + it('should not cleanup when port differs', function () { + const lock = { + port: 9999, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + cleanupOrphanedSessions(testPort); + + // Should still exist (different port) + assert.strictEqual(fs.existsSync(sessionLockPath), true); + }); + + it('should cleanup when proxy is dead', function () { + const lock = { + port: testPort, + pid: 999999999, // Dead PID + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + cleanupOrphanedSessions(testPort); + + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + + it('should not cleanup when proxy is alive', function () { + const lock = { + port: testPort, + pid: process.pid, // Current process - alive + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + cleanupOrphanedSessions(testPort); + + assert.strictEqual(fs.existsSync(sessionLockPath), true); + }); + }); + + describe('Multi-session scenario', function () { + it('should handle complete multi-terminal workflow', function () { + // Terminal 1 starts - first session + const session1 = registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 1); + + // Terminal 2 starts - joins existing + const session2 = registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 2); + + // Terminal 3 starts - joins existing + const session3 = registerSession(testPort, process.pid); + assert.strictEqual(getSessionCount(), 3); + + // Terminal 1 exits - should NOT kill proxy + let shouldKill = unregisterSession(session1); + assert.strictEqual(shouldKill, false); + assert.strictEqual(getSessionCount(), 2); + + // Terminal 3 exits - should NOT kill proxy + shouldKill = unregisterSession(session3); + assert.strictEqual(shouldKill, false); + assert.strictEqual(getSessionCount(), 1); + + // Terminal 2 exits - SHOULD kill proxy (last session) + shouldKill = unregisterSession(session2); + assert.strictEqual(shouldKill, true); + assert.strictEqual(getSessionCount(), 0); + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + }); +}); diff --git a/ui/public/assets/providers/iflow.png b/ui/public/assets/providers/iflow.png new file mode 100644 index 00000000..82edaaec Binary files /dev/null and b/ui/public/assets/providers/iflow.png differ diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 032a7cf8..dc10a280 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; import { ThemeProvider } from '@/components/theme-provider'; +import { PrivacyProvider } from '@/contexts/privacy-context'; import { Layout } from '@/components/layout'; // Eager load: HomePage (initial route) @@ -33,22 +34,24 @@ export default function App() { return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index c23e95ea..05fa86d4 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils'; import { ProviderIcon } from '@/components/provider-icon'; import { PROVIDER_COLORS } from '@/lib/provider-config'; import { STATUS_COLORS } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { ChevronRight, X, @@ -94,7 +95,13 @@ function formatTimelineTime(date: Date): string { } /** Connection Timeline Component - right sidebar panel */ -function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) { +function ConnectionTimeline({ + events, + privacyMode, +}: { + events: ConnectionEvent[]; + privacyMode: boolean; +}) { if (events.length === 0) { return (
@@ -149,7 +156,12 @@ function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) { {/* Event content */}
- + {cleanEmail(event.accountEmail)} @@ -217,6 +229,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { const [selectedAccount, setSelectedAccount] = useState(null); const [paths, setPaths] = useState([]); + // Privacy mode for demo purposes + const { privacyMode } = usePrivacy(); + // Drag state for all cards (account IDs + 'provider') const [dragOffsets, setDragOffsets] = useState>({}); const [draggingId, setDraggingId] = useState(null); @@ -494,7 +509,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { {/* Drag handle indicator */}
- + {cleanEmail(account.email)} - + {cleanEmail(account.email)}
@@ -704,7 +729,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { {/* Right Section: Connection Timeline - Fixed compact width */}
- +
@@ -733,7 +758,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { className="w-2 h-2 rounded-full" style={{ backgroundColor: selectedAccount.color }} /> - + {cleanEmail(selectedAccount.email)}
diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx index 4f812050..f6bfccc1 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/auth-monitor.tsx @@ -12,6 +12,7 @@ import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config'; import { Skeleton } from '@/components/ui/skeleton'; import { ProviderIcon } from '@/components/provider-icon'; import { AccountFlowViz } from '@/components/account-flow-viz'; +import { usePrivacy } from '@/contexts/privacy-context'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react'; @@ -114,6 +115,7 @@ function InlineStatsBadge({ success, failure }: { success: number; failure: numb export function AuthMonitor() { const { data, isLoading, error } = useCliproxyAuth(); const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats(); + const { privacyMode } = usePrivacy(); const [selectedProvider, setSelectedProvider] = useState(null); const [hoveredProvider, setHoveredProvider] = useState(null); const [timeSinceUpdate, setTimeSinceUpdate] = useState(''); @@ -406,7 +408,7 @@ export function AuthMonitor() { key={acc.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: acc.color }} - title={cleanEmail(acc.email)} + title={privacyMode ? '••••••' : cleanEmail(acc.email)} /> ))} {ps.accounts.length > 5 && ( diff --git a/ui/src/components/cliproxy-dialog.tsx b/ui/src/components/cliproxy-dialog.tsx index e91c1755..f699305d 100644 --- a/ui/src/components/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy-dialog.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { usePrivacy } from '@/contexts/privacy-context'; const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const; @@ -43,6 +44,7 @@ const providerOptions = [ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const createMutation = useCreateVariant(); const { data: authData } = useCliproxyAuth(); + const { privacyMode } = usePrivacy(); const { register, @@ -115,7 +117,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {providerAccounts.map((acc) => ( ))} diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index a1833739..a1876ee9 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled'; @@ -20,6 +21,7 @@ interface CredentialRowProps { email?: string; expiresAt?: string; onRefresh?: () => void; + privacyMode?: boolean; } function CredentialRow({ @@ -30,6 +32,7 @@ function CredentialRow({ email, expiresAt, onRefresh, + privacyMode, }: CredentialRowProps) { const statusConfig = { ready: { @@ -76,7 +79,9 @@ function CredentialRow({
-
{email ?? name}
+
+ {email ?? name} +
{provider}
@@ -124,6 +129,7 @@ function CredentialHealthSkeleton() { export function CredentialHealthList() { const { data: authData, isLoading } = useCliproxyAuth(); + const { privacyMode } = usePrivacy(); if (isLoading) { return ; @@ -164,7 +170,11 @@ export function CredentialHealthList() { {credentials.map((cred, i) => ( - + ))} diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 3d8379bd..d69eb1ae 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -57,6 +57,7 @@ import { useDeletePreset, } from '@/hooks/use-cliproxy'; import { cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; // Lazy load CodeEditor const CodeEditor = lazy(() => @@ -100,6 +101,7 @@ export function ProviderEditor({ const [conflictDialog, setConflictDialog] = useState(false); const [customPresetOpen, setCustomPresetOpen] = useState(false); const queryClient = useQueryClient(); + const { privacyMode } = usePrivacy(); // Fetch available models from CLIProxy API const { data: modelsData } = useCliproxyModels(); @@ -343,9 +345,9 @@ export function ProviderEditor({ ))} + + +

+ {privacyMode + ? 'Privacy mode ON - Click to show data' + : 'Privacy mode OFF - Click to hide data'} +

+
+ + ); +} diff --git a/ui/src/components/quick-setup-wizard.tsx b/ui/src/components/quick-setup-wizard.tsx index dfc3d6b4..1fc36d70 100644 --- a/ui/src/components/quick-setup-wizard.tsx +++ b/ui/src/components/quick-setup-wizard.tsx @@ -39,6 +39,8 @@ import { import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { cn } from '@/lib/utils'; +import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; interface QuickSetupWizardProps { open: boolean; @@ -68,6 +70,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const { data: authData, refetch } = useCliproxyAuth(); const createMutation = useCreateVariant(); const startAuthMutation = useStartAuth(); + const { privacyMode } = usePrivacy(); // Get auth status for selected provider const providerAuth = authData?.authStatus.find( @@ -347,7 +350,9 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
-
{acc.email || acc.id}
+
+ {acc.email || acc.id} +
{acc.isDefault && (
Default account
)} @@ -403,7 +408,12 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { {selectedAccount && (
- Using: {selectedAccount.email || selectedAccount.id} + + Using:{' '} + + {selectedAccount.email || selectedAccount.id} + +
)} diff --git a/ui/src/contexts/privacy-context.tsx b/ui/src/contexts/privacy-context.tsx new file mode 100644 index 00000000..98cc98fb --- /dev/null +++ b/ui/src/contexts/privacy-context.tsx @@ -0,0 +1,54 @@ +/** + * Privacy Context - Global demo mode for censoring personal information + * Provides blur effect on emails, account IDs, and other PII + * Persists state to localStorage + */ + +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; + +interface PrivacyContextValue { + /** Whether privacy/demo mode is enabled */ + privacyMode: boolean; + /** Toggle privacy mode on/off */ + togglePrivacyMode: () => void; +} + +const PrivacyContext = createContext(null); + +const STORAGE_KEY = 'ccs-privacy-mode'; + +export function PrivacyProvider({ children }: { children: ReactNode }) { + const [privacyMode, setPrivacyMode] = useState(() => { + // Initialize from localStorage + if (typeof window !== 'undefined') { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } + return false; + }); + + // Persist to localStorage when changed + useEffect(() => { + localStorage.setItem(STORAGE_KEY, String(privacyMode)); + }, [privacyMode]); + + const togglePrivacyMode = () => setPrivacyMode((prev) => !prev); + + return ( + + {children} + + ); +} + +export function usePrivacy() { + const context = useContext(PrivacyContext); + if (!context) { + throw new Error('usePrivacy must be used within a PrivacyProvider'); + } + return context; +} + +/** CSS class for blurring sensitive content */ +export const PRIVACY_BLUR_CLASS = + 'blur-[4px] select-none hover:blur-none transition-all duration-200'; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 2e0dfa17..981ff7c1 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -247,7 +247,7 @@ export function CliproxyPage() {

CCS-level account management