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) { diff --git a/src/web-server/file-watcher.ts b/src/web-server/file-watcher.ts index cb4c425d..a57bce39 100644 --- a/src/web-server/file-watcher.ts +++ b/src/web-server/file-watcher.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; export interface FileChangeEvent { - type: 'config-changed' | 'settings-changed' | 'profiles-changed'; + type: 'config-changed' | 'settings-changed' | 'profiles-changed' | 'proxy-status-changed'; path: string; timestamp: number; } @@ -25,6 +25,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { path.join(ccsDir, 'config.json'), path.join(ccsDir, '*.settings.json'), path.join(ccsDir, 'profiles.json'), + path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking ], { persistent: true, @@ -44,6 +45,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher { type = 'config-changed'; } else if (basename === 'profiles.json') { type = 'profiles-changed'; + } else if (basename === 'sessions.json') { + type = 'proxy-status-changed'; } else { type = 'settings-changed'; } diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 493cc006..9a4486fd 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -40,6 +40,8 @@ import { } from '../cliproxy/account-manager'; import type { CLIProxyProvider } from '../cliproxy/types'; import { getClaudeEnvVars } from '../cliproxy/config-generator'; +import { getProxyStatus as getProxyProcessStatus } from '../cliproxy/session-tracker'; +import { ensureCliproxyService } from '../cliproxy/service-manager'; // Unified config imports import { hasUnifiedConfig, @@ -1290,6 +1292,56 @@ apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise< } }); +/** + * GET /api/cliproxy/proxy-status - Get detailed proxy process status + * Returns: { running, port?, pid?, sessionCount?, startedAt? } + * Combines session tracker data with actual port check for accuracy + */ +apiRoutes.get('/cliproxy/proxy-status', async (_req: Request, res: Response): Promise => { + try { + // First check session tracker for detailed info + const sessionStatus = getProxyProcessStatus(); + + // If session tracker says running, trust it + if (sessionStatus.running) { + res.json(sessionStatus); + return; + } + + // Session tracker says not running, but proxy might be running without session tracking + // (e.g., started before session persistence was implemented) + const actuallyRunning = await isCliproxyRunning(); + + if (actuallyRunning) { + // Proxy running but no session lock - legacy/untracked instance + res.json({ + running: true, + port: 8317, // Default port + sessionCount: 0, // Unknown sessions + // No pid/startedAt since we don't have session lock + }); + } else { + res.json(sessionStatus); + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cliproxy/proxy-start - Start the CLIProxy service + * Returns: { started, alreadyRunning, port, error? } + * Starts proxy in background if not already running + */ +apiRoutes.post('/cliproxy/proxy-start', async (_req: Request, res: Response): Promise => { + try { + const result = await ensureCliproxyService(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/models - Get available models from CLIProxyAPI * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js index 6625885c..14f8aa0e 100644 --- a/tests/unit/cliproxy/session-tracker.test.js +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -21,6 +21,8 @@ const { getSessionCount, hasActiveSessions, cleanupOrphanedSessions, + stopProxy, + getProxyStatus, } = require('../../../dist/cliproxy/session-tracker'); describe('Session Tracker', function () { @@ -296,6 +298,97 @@ describe('Session Tracker', function () { }); }); + describe('stopProxy', function () { + it('should return error when no lock exists', function () { + const result = stopProxy(); + assert.strictEqual(result.stopped, false); + assert.strictEqual(result.error, 'No active CLIProxy session found'); + }); + + it('should cleanup stale lock when proxy is not running', function () { + // Create lock with dead 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 = stopProxy(); + assert.strictEqual(result.stopped, false); + assert.ok(result.error.includes('not running')); + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + + it('should return pid and session count on success', function () { + // Register a session with current process + registerSession(testPort, process.pid); + + // Note: We can't actually test killing our own process, + // but we can verify the structure is correct before it attempts kill + const status = getProxyStatus(); + assert.strictEqual(status.running, true); + assert.strictEqual(status.pid, process.pid); + assert.strictEqual(status.sessionCount, 1); + }); + }); + + describe('getProxyStatus', function () { + it('should return not running when no lock exists', function () { + const result = getProxyStatus(); + assert.strictEqual(result.running, false); + assert.strictEqual(result.port, undefined); + assert.strictEqual(result.pid, undefined); + }); + + it('should return full status when proxy is running', function () { + const startedAt = new Date().toISOString(); + const lock = { + port: testPort, + pid: process.pid, // Current process - alive + sessions: ['session1', 'session2'], + startedAt, + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock)); + + const result = getProxyStatus(); + assert.strictEqual(result.running, true); + assert.strictEqual(result.port, testPort); + assert.strictEqual(result.pid, process.pid); + assert.strictEqual(result.sessionCount, 2); + assert.strictEqual(result.startedAt, startedAt); + }); + + it('should cleanup and return not running 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)); + + const result = getProxyStatus(); + assert.strictEqual(result.running, false); + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + + it('should return correct session count after registrations', function () { + registerSession(testPort, process.pid); + let status = getProxyStatus(); + assert.strictEqual(status.sessionCount, 1); + + registerSession(testPort, process.pid); + status = getProxyStatus(); + assert.strictEqual(status.sessionCount, 2); + + registerSession(testPort, process.pid); + status = getProxyStatus(); + assert.strictEqual(status.sessionCount, 3); + }); + }); + describe('Multi-session scenario', function () { it('should handle complete multi-terminal workflow', function () { // Terminal 1 starts - first session diff --git a/tests/unit/commands/cliproxy-command.test.js b/tests/unit/commands/cliproxy-command.test.js index a92dea23..2f7c2a97 100644 --- a/tests/unit/commands/cliproxy-command.test.js +++ b/tests/unit/commands/cliproxy-command.test.js @@ -357,3 +357,156 @@ describe('API Command - Model Fields Fix', () => { }); }); }); + +describe('CLIProxy Command - Proxy Lifecycle', () => { + // Test isolation environment + let testHome; + let sessionLockPath; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-lifecycle-')); + process.env.CCS_HOME = testHome; + const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + sessionLockPath = path.join(cliproxyDir, 'sessions.json'); + }); + + afterEach(() => { + if (testHome && fs.existsSync(testHome)) { + fs.rmSync(testHome, { recursive: true, force: true }); + } + delete process.env.CCS_HOME; + }); + + describe('Status Command Logic', () => { + it('returns not running when no session lock exists', () => { + // Simulate getProxyStatus behavior + const lockExists = fs.existsSync(sessionLockPath); + assert.strictEqual(lockExists, false); + + // Status should indicate not running + const status = { running: false }; + assert.strictEqual(status.running, false); + }); + + it('returns running with details when session lock exists', () => { + // Create mock session lock + const lock = { + port: 8317, + pid: process.pid, + sessions: ['session1', 'session2'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2)); + + // Read and verify + const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(data.port, 8317); + assert.strictEqual(data.pid, process.pid); + assert.strictEqual(data.sessions.length, 2); + assert(data.startedAt); + }); + + it('formats uptime correctly', () => { + // Test uptime formatting logic + const formatUptime = (ms) => { + const hours = Math.floor(ms / (1000 * 60 * 60)); + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; + }; + + assert.strictEqual(formatUptime(30 * 60 * 1000), '30m'); // 30 minutes + assert.strictEqual(formatUptime(90 * 60 * 1000), '1h 30m'); // 1.5 hours + assert.strictEqual(formatUptime(2 * 60 * 60 * 1000 + 15 * 60 * 1000), '2h 15m'); // 2h 15m + }); + }); + + describe('Stop Command Logic', () => { + it('returns error when no session lock exists', () => { + // Verify lock doesn't exist + assert.strictEqual(fs.existsSync(sessionLockPath), false); + + // Stop should fail with appropriate error + const result = { stopped: false, error: 'No active CLIProxy session found' }; + assert.strictEqual(result.stopped, false); + assert.strictEqual(result.error, 'No active CLIProxy session found'); + }); + + it('cleans up stale lock for dead process', () => { + // Create lock with non-existent PID + const lock = { + port: 8317, + pid: 999999999, // Very unlikely to exist + sessions: ['session1'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2)); + + // Verify lock was created + assert.strictEqual(fs.existsSync(sessionLockPath), true); + + // Simulate isProcessRunning check + const isRunning = (() => { + try { + process.kill(999999999, 0); + return true; + } catch { + return false; + } + })(); + + assert.strictEqual(isRunning, false); + + // Cleanup should remove lock + if (!isRunning) { + fs.unlinkSync(sessionLockPath); + } + assert.strictEqual(fs.existsSync(sessionLockPath), false); + }); + + it('returns session count when stopping active proxy', () => { + // Create lock with current process + const lock = { + port: 8317, + pid: process.pid, + sessions: ['session1', 'session2', 'session3'], + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(sessionLockPath, JSON.stringify(lock, null, 2)); + + // Read and verify session count + const data = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8')); + assert.strictEqual(data.sessions.length, 3); + + // Result structure should include count + const result = { + stopped: true, + pid: data.pid, + sessionCount: data.sessions.length, + }; + assert.strictEqual(result.stopped, true); + assert.strictEqual(result.sessionCount, 3); + }); + }); + + describe('Command Routing', () => { + it('routes "stop" subcommand correctly', () => { + const args = ['cliproxy', 'stop']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'stop'); + }); + + it('routes "status" subcommand correctly', () => { + const args = ['cliproxy', 'status']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'status'); + }); + + it('handles unknown subcommand', () => { + const validSubcommands = ['stop', 'status', 'create', 'list', 'remove']; + const unknownCommand = 'invalid'; + assert.strictEqual(validSubcommands.includes(unknownCommand), false); + }); + }); +}); diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx new file mode 100644 index 00000000..a85981c2 --- /dev/null +++ b/ui/src/components/proxy-status-widget.tsx @@ -0,0 +1,100 @@ +/** + * Proxy Status Widget + * + * Displays CLIProxy process status with start button for recovery. + * Shows: running state, port, session count, uptime. + */ + +import { Activity, Power, RefreshCw, Clock, Users } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useProxyStatus, useStartProxy } from '@/hooks/use-cliproxy'; +import { cn } from '@/lib/utils'; + +function formatUptime(startedAt?: string): string { + if (!startedAt) return ''; + const start = new Date(startedAt).getTime(); + const now = Date.now(); + const diff = now - start; + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +export function ProxyStatusWidget() { + const { data: status, isLoading } = useProxyStatus(); + const startProxy = useStartProxy(); + + const isRunning = status?.running ?? false; + + return ( +
+
+
+
+ CLIProxy Service +
+ +
+ {isLoading ? ( + + ) : isRunning ? ( + + ) : ( + + )} +
+
+ + {isRunning && status ? ( +
+ Port {status.port} + {status.sessionCount !== undefined && status.sessionCount > 0 && ( + + + {status.sessionCount} session{status.sessionCount !== 1 ? 's' : ''} + + )} + {status.startedAt && ( + + + {formatUptime(status.startedAt)} + + )} +
+ ) : ( +
+ Not running + +
+ )} +
+ ); +} diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 5b92eb8f..9f23842e 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -209,3 +209,34 @@ export function useDeletePreset() { }, }); } + +// ==================== Proxy Process Status ==================== + +export function useProxyStatus() { + return useQuery({ + queryKey: ['proxy-status'], + queryFn: () => api.cliproxy.proxyStatus(), + refetchInterval: 30000, // Refresh every 30s as backup (websocket is primary) + }); +} + +export function useStartProxy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => api.cliproxy.proxyStart(), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + if (data.alreadyRunning) { + toast.info('CLIProxy was already running'); + } else if (data.started) { + toast.success('CLIProxy started successfully'); + } else { + toast.error(data.error || 'Failed to start CLIProxy'); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 8091ad13..f365bd1d 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -48,6 +48,10 @@ export function useWebSocket() { toast.info('Accounts updated'); break; + case 'proxy-status-changed': + queryClient.invalidateQueries({ queryKey: ['proxy-status'] }); + break; + case 'pong': // Heartbeat response break; diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 3419796e..7ca955b4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -154,6 +154,24 @@ export interface CreatePreset { haiku?: string; } +/** CLIProxy process status from session tracker */ +export interface ProxyProcessStatus { + running: boolean; + port?: number; + pid?: number; + sessionCount?: number; + startedAt?: string; +} + +/** Result from starting proxy service */ +export interface ProxyStartResult { + started: boolean; + alreadyRunning: boolean; + port: number; + configRegenerated?: boolean; + error?: string; +} + // API export const api = { profiles: { @@ -185,6 +203,10 @@ export const api = { }), delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }), + // Proxy process status and control + proxyStatus: () => request('/cliproxy/proxy-status'), + proxyStart: () => request('/cliproxy/proxy-start', { method: 'POST' }), + // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), models: () => request('/cliproxy/models'), diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 981ff7c1..b296c7fe 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard'; import { AddAccountDialog } from '@/components/add-account-dialog'; import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { ProxyStatusWidget } from '@/components/proxy-status-widget'; import { useCliproxy, useCliproxyAuth, @@ -307,6 +308,11 @@ export function CliproxyPage() {
+ {/* Proxy Status Widget */} +
+ +
+ {/* Footer Stats */}