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 (