mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user