diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index f874a8dd..bef6a2fa 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -318,13 +318,11 @@ export async function execClaudeWithCLIProxy( 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)`) ); @@ -363,26 +361,20 @@ export async function execClaudeWithCLIProxy( } // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + // Use detached mode so proxy persists after terminal closes const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'], - detached: false, + stdio: ['ignore', 'ignore', 'ignore'], + detached: true, // Persist after parent terminal closes }); - // 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()}`); - }); - } + // Unref so parent process can exit independently + proxy.unref(); - // Handle proxy errors + // Handle proxy errors (only fires if spawn itself fails) proxy.on('error', (error) => { console.error(fail(`CLIProxy spawn error: ${error.message}`)); }); @@ -478,25 +470,14 @@ export async function execClaudeWithCLIProxy( }); } - // 8. Cleanup: unregister session when Claude exits, kill proxy only if last session + // 8. Cleanup: unregister session when Claude exits + // Proxy persists by default - use 'ccs cliproxy stop' to kill manually claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - // 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`); - } + // Unregister this session (proxy keeps running for persistence) + unregisterSession(sessionId); + log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -508,11 +489,8 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); - // Unregister and conditionally kill proxy - const shouldKillProxy = unregisterSession(sessionId); - if (shouldKillProxy && proxy) { - proxy.kill('SIGTERM'); - } + // Unregister session, proxy keeps running + unregisterSession(sessionId); process.exit(1); }); @@ -520,26 +498,13 @@ export async function execClaudeWithCLIProxy( const cleanup = () => { log('Parent signal received, cleaning up'); - // Unregister and conditionally kill proxy - const shouldKillProxy = unregisterSession(sessionId); - if (shouldKillProxy && proxy) { - proxy.kill('SIGTERM'); - } + // Unregister session, proxy keeps running + unregisterSession(sessionId); claude.kill('SIGTERM'); }; process.once('SIGTERM', cleanup); process.once('SIGINT', cleanup); - - // 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 index 0f1697c8..1b35657c 100644 --- a/src/cliproxy/session-tracker.ts +++ b/src/cliproxy/session-tracker.ts @@ -224,3 +224,78 @@ export function cleanupOrphanedSessions(port: number): void { deleteSessionLock(); } } + +/** + * Stop the CLIProxy process and clean up session lock. + * @returns Object with success status and details + */ +export function stopProxy(): { + stopped: boolean; + pid?: number; + sessionCount?: number; + error?: string; +} { + const lock = readSessionLock(); + + if (!lock) { + return { stopped: false, error: 'No active CLIProxy session found' }; + } + + // Check if proxy is running + if (!isProcessRunning(lock.pid)) { + deleteSessionLock(); + return { stopped: false, error: 'CLIProxy was not running (cleaned up stale lock)' }; + } + + const sessionCount = lock.sessions.length; + const pid = lock.pid; + + try { + // Kill the proxy process + process.kill(pid, 'SIGTERM'); + + // Clean up session lock + deleteSessionLock(); + + return { stopped: true, pid, sessionCount }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process already gone + deleteSessionLock(); + return { stopped: false, error: 'CLIProxy process already terminated' }; + } + return { stopped: false, pid, error: `Failed to stop: ${error.message}` }; + } +} + +/** + * Get proxy status information. + */ +export function getProxyStatus(): { + running: boolean; + port?: number; + pid?: number; + sessionCount?: number; + startedAt?: string; +} { + const lock = readSessionLock(); + + if (!lock) { + return { running: false }; + } + + // Verify proxy is still running + if (!isProcessRunning(lock.pid)) { + deleteSessionLock(); + return { running: false }; + } + + return { + running: true, + port: lock.port, + pid: lock.pid, + sessionCount: lock.sessions.length, + startedAt: lock.startedAt, + }; +} diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index 59d9dcb9..cd492753 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -60,6 +60,7 @@ import { saveUnifiedConfig, } from '../config/unified-config-loader'; import { isUnifiedConfigEnabled } from '../config/feature-flags'; +import { stopProxy, getProxyStatus } from '../cliproxy/session-tracker'; // ============================================================================ // PROFILE MANAGEMENT @@ -804,6 +805,20 @@ async function showHelp(): Promise { } console.log(''); + // Proxy Lifecycle Commands + console.log(subheader('Proxy Lifecycle:')); + const lifecycleCmds: [string, string][] = [ + ['status', 'Show running CLIProxy status'], + ['stop', 'Stop running CLIProxy instance'], + ]; + const maxLifecycleLen = Math.max(...lifecycleCmds.map(([cmd]) => cmd.length)); + for (const [cmd, desc] of lifecycleCmds) { + console.log(` ${color(cmd.padEnd(maxLifecycleLen + 2), 'command')} ${desc}`); + } + console.log(''); + console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); + console.log(''); + // Binary Commands console.log(subheader('Binary Commands:')); const binaryCmds: [string, string][] = [ @@ -1041,6 +1056,62 @@ async function installLatest(verbose: boolean): Promise { } } +// ============================================================================ +// PROXY LIFECYCLE COMMANDS +// ============================================================================ + +/** + * Handle 'ccs cliproxy stop' - Stop running CLIProxy instance + */ +async function handleStop(): Promise { + await initUI(); + + console.log(header('Stop CLIProxy')); + console.log(''); + + const result = stopProxy(); + + if (result.stopped) { + console.log(ok(`CLIProxy stopped (PID ${result.pid})`)); + if (result.sessionCount && result.sessionCount > 0) { + console.log(info(`${result.sessionCount} active session(s) were disconnected`)); + } + } else { + console.log(warn(result.error || 'Failed to stop CLIProxy')); + } + console.log(''); +} + +/** + * Handle 'ccs cliproxy status' - Show running proxy status + */ +async function handleProxyStatus(): Promise { + await initUI(); + + console.log(header('CLIProxy Status')); + console.log(''); + + const status = getProxyStatus(); + + if (status.running) { + console.log(` Status: ${color('Running', 'success')}`); + console.log(` PID: ${status.pid}`); + console.log(` Port: ${status.port}`); + console.log(` Sessions: ${status.sessionCount || 0} active`); + if (status.startedAt) { + const started = new Date(status.startedAt); + console.log(` Started: ${started.toLocaleString()}`); + } + console.log(''); + console.log(dim('To stop: ccs cliproxy stop')); + } else { + console.log(` Status: ${color('Not running', 'warning')}`); + console.log(''); + console.log(dim('CLIProxy starts automatically when you run ccs gemini, codex, etc.')); + } + console.log(''); +} + // ============================================================================ // MAIN ROUTER // ============================================================================ @@ -1074,6 +1145,17 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } + // Handle proxy lifecycle commands + if (command === 'stop') { + await handleStop(); + return; + } + + if (command === 'status') { + await handleProxyStatus(); + return; + } + // Handle --install const installIdx = args.indexOf('--install'); if (installIdx !== -1) {