feat(cliproxy): default session persistence for CLIProxy

CLIProxy now persists by default after terminal closes:
- Spawn proxy with detached mode (survives terminal close)
- Remove auto-kill on Claude exit (proxy keeps running)
- Add `ccs cliproxy stop` to explicitly terminate proxy
- Add `ccs cliproxy status` to show running proxy info

Closes #129
This commit is contained in:
kaitranntt
2025-12-18 00:57:09 -05:00
parent 7d03933f44
commit a7450bdffe
3 changed files with 172 additions and 50 deletions
+15 -50
View File
@@ -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
}
});
}
}
/**
+75
View File
@@ -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,
};
}
+82
View File
@@ -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<void> {
}
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<void> {
}
}
// ============================================================================
// PROXY LIFECYCLE COMMANDS
// ============================================================================
/**
* Handle 'ccs cliproxy stop' - Stop running CLIProxy instance
*/
async function handleStop(): Promise<void> {
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<void> {
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<void> {
return;
}
// Handle proxy lifecycle commands
if (command === 'stop') {
await handleStop();
return;
}
if (command === 'status') {
await handleProxyStatus();
return;
}
// Handle --install <version>
const installIdx = args.indexOf('--install');
if (installIdx !== -1) {