diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index cfb9430d..940c157e 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -180,16 +180,17 @@ export function applyThinkingConfig( // Get base model to check thinking support const baseModel = result.ANTHROPIC_MODEL || ''; const normalizedBaseModel = normalizeModelForThinkingLookup(baseModel, provider); - if (!supportsThinking(provider, normalizedBaseModel)) { - // U2: Warn user if they explicitly provided --thinking but model doesn't support it - if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) { - console.warn( - warn( - `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.` - ) - ); - } - return result; + const baseModelSupportsThinking = supportsThinking(provider, normalizedBaseModel); + if ( + !baseModelSupportsThinking && + thinkingOverride !== undefined && + shouldShowWarnings(thinkingConfig) + ) { + console.warn( + warn( + `Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored for default model.` + ) + ); } // Determine thinking value to use @@ -215,12 +216,14 @@ export function applyThinkingConfig( return result; // No thinking to apply } - // Validate thinking value against model capabilities - const validation = validateThinking(provider, normalizedBaseModel, thinkingValue); - if (validation.warning && shouldShowWarnings(thinkingConfig)) { - console.warn(warn(validation.warning)); + if (baseModelSupportsThinking) { + // Validate thinking value against default model capabilities. + const validation = validateThinking(provider, normalizedBaseModel, thinkingValue); + if (validation.warning && shouldShowWarnings(thinkingConfig)) { + console.warn(warn(validation.warning)); + } + thinkingValue = validation.value; } - thinkingValue = validation.value; // If auto-detection resolves default tier to "off", skip the main model but still allow // explicit per-tier thinking values for other tiers. @@ -232,7 +235,7 @@ export function applyThinkingConfig( return result; // No thinking to apply anywhere } // Otherwise, continue to process tiers with their own config (skip main model) - } else if (result.ANTHROPIC_MODEL) { + } else if (baseModelSupportsThinking && result.ANTHROPIC_MODEL) { // Apply thinking suffix to main model (only if not off) result.ANTHROPIC_MODEL = applyThinkingSuffixForProvider( result.ANTHROPIC_MODEL, diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts index b8fa43de..b67ed7a3 100644 --- a/src/cliproxy/services/index.ts +++ b/src/cliproxy/services/index.ts @@ -22,10 +22,12 @@ export { export { getProxyStatus, stopProxy, + startProxy, isProxyRunning, getActiveSessionCount, type ProxyStatusResult, type StopProxyResult, + type StartProxyResult, } from './proxy-lifecycle-service'; // Binary management diff --git a/src/cliproxy/services/proxy-lifecycle-service.ts b/src/cliproxy/services/proxy-lifecycle-service.ts index 7ea4c124..4c140262 100644 --- a/src/cliproxy/services/proxy-lifecycle-service.ts +++ b/src/cliproxy/services/proxy-lifecycle-service.ts @@ -2,13 +2,15 @@ * CLIProxy Proxy Lifecycle Service * * Handles start/stop/status operations for CLIProxy instances. - * Delegates to session-tracker for actual process management. + * Delegates to session-tracker and service-manager for actual process management. */ import { stopProxy as stopProxySession, getProxyStatus as getProxyStatusSession, } from '../session-tracker'; +import { ensureCliproxyService } from '../service-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../config-generator'; /** Proxy status result */ export interface ProxyStatusResult { @@ -27,18 +29,37 @@ export interface StopProxyResult { error?: string; } +/** Start proxy result */ +export interface StartProxyResult { + started: boolean; + alreadyRunning: boolean; + port: number; + configRegenerated?: boolean; + error?: string; +} + /** * Get current proxy status */ -export function getProxyStatus(): ProxyStatusResult { - return getProxyStatusSession(); +export function getProxyStatus(port?: number): ProxyStatusResult { + return getProxyStatusSession(port); } /** * Stop the running CLIProxy instance */ -export async function stopProxy(): Promise { - return stopProxySession(); +export async function stopProxy(port?: number): Promise { + return stopProxySession(port); +} + +/** + * Start CLIProxy service (or reuse existing running instance) + */ +export async function startProxy( + port: number = CLIPROXY_DEFAULT_PORT, + verbose: boolean = false +): Promise { + return ensureCliproxyService(port, verbose); } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 5d0dde7a..a3471c13 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -17,6 +17,7 @@ import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; import { warn } from '../../utils/ui'; +import { getCcsDir } from '../../utils/config-manager'; import { validateCompositeTiers } from '../composite-validator'; import { createSettingsFile, @@ -24,7 +25,6 @@ import { createCompositeSettingsFile, deleteSettingsFile, getRelativeSettingsPath, - getCompositeRelativeSettingsPath, updateSettingsModel, updateSettingsProviderAndModel, } from './variant-settings'; @@ -359,7 +359,7 @@ export function createCompositeVariant( type: 'composite', default_tier: defaultTier, tiers, - settings: getCompositeRelativeSettingsPath(name), + settings: settingsPath, port, }; saveCompositeVariantUnified(name, compositeConfig); @@ -436,7 +436,8 @@ export function updateCompositeVariant( } // Preserve existing settings path when configured; otherwise use default path. - const settingsRef = existing.settings || getCompositeRelativeSettingsPath(name); + const settingsRef = + existing.settings || path.join(getCcsDir(), `composite-${name}.settings.json`); // Create new settings file with updated config const settingsPath = createCompositeSettingsFile( diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index fe136927..7971c701 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -61,6 +61,8 @@ export async function showHelp(): Promise { [ 'Proxy Lifecycle:', [ + ['start', 'Start CLIProxy instance in background'], + ['restart', 'Restart CLIProxy instance'], ['status', 'Show running CLIProxy status'], ['stop', 'Stop running CLIProxy instance'], ['doctor', 'Quota diagnostics and shared project detection'], diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index bdefb355..308f9838 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -20,7 +20,12 @@ import { handleResumeAccount, } from './quota-subcommand'; import { handleCreate, handleRemove, handleEdit } from './variant-subcommand'; -import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand'; +import { + handleProxyStatus, + handleStart, + handleStop, + handleRestart, +} from './proxy-lifecycle-subcommand'; import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand'; import { showHelp } from './help-subcommand'; import { @@ -198,11 +203,21 @@ export async function handleCliproxyCommand(args: string[]): Promise { } // Proxy lifecycle commands + if (command === 'start') { + await handleStart(verbose); + return; + } + if (command === 'stop') { await handleStop(); return; } + if (command === 'restart') { + await handleRestart(verbose); + return; + } + if (command === 'status') { await handleProxyStatus(); return; diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index d59319c2..1dad5b5a 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -2,21 +2,87 @@ * CLIProxy Lifecycle Management * * Handles: + * - ccs cliproxy start + * - ccs cliproxy restart * - ccs cliproxy status * - ccs cliproxy stop */ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; -import { getProxyStatus, stopProxy } from '../../cliproxy/services'; +import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; import { detectRunningProxy } from '../../cliproxy/proxy-detector'; -import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator'; +import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + +/** + * Resolve the local CLIProxy lifecycle port from unified config. + * Falls back to default port when unset/invalid. + */ +export function resolveLifecyclePort(): number { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); +} + +export async function handleStart(verbose = false): Promise { + await initUI(); + console.log(header('Start CLIProxy')); + console.log(''); + + const port = resolveLifecyclePort(); + const result = await startProxy(port, verbose); + if (result.started) { + if (result.alreadyRunning) { + console.log(info(`CLIProxy already running on port ${result.port}`)); + if (result.configRegenerated) { + console.log(warn('Config updated - restart CLIProxy to apply changes')); + } + } else { + console.log(ok(`CLIProxy started on port ${result.port}`)); + } + console.log(dim('To stop: ccs cliproxy stop')); + } else { + console.log(warn(result.error || 'Failed to start CLIProxy')); + } + console.log(''); +} + +export async function handleRestart(verbose = false): Promise { + await initUI(); + console.log(header('Restart CLIProxy')); + console.log(''); + + const port = resolveLifecyclePort(); + const stopResult = await stopProxy(port); + if (stopResult.stopped) { + console.log(ok(`CLIProxy stopped (PID ${stopResult.pid})`)); + } else if (stopResult.error === 'No active CLIProxy session found') { + console.log(info('No active CLIProxy session found, starting a new instance')); + } else { + console.log(warn(stopResult.error || 'Failed to stop existing CLIProxy')); + console.log(info('Attempting to start a fresh instance...')); + } + + const startResult = await startProxy(port, verbose); + if (startResult.started) { + if (startResult.alreadyRunning) { + console.log(info(`CLIProxy already running on port ${startResult.port}`)); + } else { + console.log(ok(`CLIProxy started on port ${startResult.port}`)); + } + } else { + console.log(warn(startResult.error || 'Failed to restart CLIProxy')); + } + + console.log(''); +} export async function handleProxyStatus(): Promise { await initUI(); console.log(header('CLIProxy Status')); console.log(''); - const status = getProxyStatus(); + const port = resolveLifecyclePort(); + const status = getProxyStatus(port); if (status.running) { console.log(` Status: ${color('Running', 'success')}`); console.log(` PID: ${status.pid}`); @@ -29,11 +95,11 @@ export async function handleProxyStatus(): Promise { console.log(dim('To stop: ccs cliproxy stop')); } else { // Fallback: detect untracked/orphaned proxy process (e.g. detached session without lock file). - const detected = await detectRunningProxy(); + const detected = await detectRunningProxy(port); if (detected.running && detected.verified) { console.log(` Status: ${color('Running', 'success')}`); console.log(` PID: ${detected.pid ?? 'unknown'}`); - console.log(` Port: ${CLIPROXY_DEFAULT_PORT}`); + console.log(` Port: ${port}`); console.log(` Sessions: ${detected.sessionCount || 0} active`); if (!detected.sessionCount) { console.log(dim(' Note: Detected running proxy without local session lock')); @@ -54,7 +120,8 @@ export async function handleStop(): Promise { console.log(header('Stop CLIProxy')); console.log(''); - const result = await stopProxy(); + const port = resolveLifecyclePort(); + const result = await stopProxy(port); if (result.stopped) { console.log(ok(`CLIProxy stopped (PID ${result.pid})`)); if (result.sessionCount && result.sessionCount > 0) { diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 5985b81d..bef16185 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -5,7 +5,7 @@ * Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend. */ -import { spawn, ChildProcess } from 'child_process'; +import { spawn, spawnSync, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as http from 'http'; @@ -77,8 +77,26 @@ export async function isDaemonRunning(port: number): Promise { timeout: 3000, }, (res) => { - res.resume(); // Drain response body - resolve(res.statusCode === 200); + let body = ''; + res.setEncoding('utf8'); + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + resolve(false); + return; + } + + try { + const payload = JSON.parse(body) as { service?: string }; + resolve(payload.service === 'cursor-daemon'); + } catch { + resolve(false); + } + }); } ); @@ -95,6 +113,79 @@ export async function isDaemonRunning(port: number): Promise { }); } +type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown'; + +function getProcessCommandLine(pid: number): string | null { + if (process.platform === 'linux') { + try { + // /proc cmdline uses null separators between arguments. + return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim(); + } catch { + return null; + } + } + + if (process.platform === 'darwin') { + try { + const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + }); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout.trim(); + } catch { + return null; + } + } + + if (process.platform === 'win32') { + const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine)`; + const shells = ['powershell.exe', 'powershell', 'pwsh.exe', 'pwsh']; + for (const shell of shells) { + try { + const result = spawnSync(shell, ['-NoProfile', '-Command', command], { + encoding: 'utf8', + }); + if (result.error) { + continue; + } + if (result.status !== 0) { + return null; + } + return result.stdout.trim(); + } catch { + // Try next shell candidate + } + } + return null; + } + + return null; +} + +function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus { + try { + process.kill(pid, 0); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + return 'not-running'; + } + return 'unknown'; + } + + const commandLine = getProcessCommandLine(pid); + if (!commandLine) { + return 'unknown'; + } + + const looksLikeCursorDaemon = + commandLine.includes('--ccs-daemon') && commandLine.includes('cursor-daemon-entry'); + + return looksLikeCursorDaemon ? 'owned' : 'not-owned'; +} + /** * Get daemon status. */ @@ -292,16 +383,23 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string } } try { - // Verify the PID belongs to our daemon before signaling - try { - const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); - if (!cmdline.includes('--ccs-daemon')) { - // PID was reused by an unrelated process - removePidFile(); - return { success: true }; - } - } catch { - // /proc not available (macOS/Windows) or process gone — proceed with kill + const ownership = verifyDaemonOwnership(pid); + if (ownership === 'not-running') { + removePidFile(); + return { success: true }; + } + + if (ownership === 'not-owned') { + // PID was reused by an unrelated process. + removePidFile(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; } // Send SIGTERM to the process diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 2464c31a..f217de64 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -334,6 +334,32 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); }); + it('applies tier thinking even when default model does not support thinking', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig( + envVars, + 'codex' as CLIProxyProvider, + undefined, + { + sonnet: 'high', + }, + { + sonnet: { provider: 'agy' as CLIProxyProvider }, + } + ); + + // Default model provider/model do not support thinking. + expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini'); + // Supported mixed-provider tier must still receive its configured thinking value. + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); + }); + it('should handle numeric budgets in per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', @@ -468,13 +494,11 @@ describe('applyThinkingConfig - composite variant integration', () => { compositeTierThinking ); - // Base model check uses ANTHROPIC_MODEL with suffix — supportsThinking - // may not recognize suffixed model names, causing early return. - // All models preserved as-is. + // Existing suffix on main model should be preserved. expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); - // Opus stays unchanged because function returned early - expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + // Other supported tiers still receive configured thinking. + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)'); }); it('should use codex effort suffix style when provider is codex', () => { diff --git a/tests/unit/cliproxy/composite-variant-service.test.ts b/tests/unit/cliproxy/composite-variant-service.test.ts index 0ab54d77..22ad8ae8 100644 --- a/tests/unit/cliproxy/composite-variant-service.test.ts +++ b/tests/unit/cliproxy/composite-variant-service.test.ts @@ -407,6 +407,26 @@ cliproxy: expect(result.success).toBe(false); expect(result.error).toContain("Invalid tier config for 'opus'"); }); + + it('stores composite settings path under active CCS_DIR', () => { + const result = createCompositeVariant({ + name: 'scoped', + defaultTier: 'sonnet', + tiers: { + opus: { provider: 'gemini', model: 'gemini-2.5-pro' }, + sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' }, + haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' }, + }, + }); + + expect(result.success).toBe(true); + + const variants = listVariantsFromConfig(); + const expectedPath = path.join(tmpDir, 'composite-scoped.settings.json'); + expect(variants.scoped.settings).toBe(expectedPath); + expect(result.settingsPath).toBe(expectedPath); + expect(fs.existsSync(expectedPath)).toBe(true); + }); }); describe('saveCompositeVariantUnified', () => { diff --git a/tests/unit/commands/cliproxy-command.test.js b/tests/unit/commands/cliproxy-command.test.js index 2f7c2a97..e6fc3a0b 100644 --- a/tests/unit/commands/cliproxy-command.test.js +++ b/tests/unit/commands/cliproxy-command.test.js @@ -491,6 +491,18 @@ describe('CLIProxy Command - Proxy Lifecycle', () => { }); describe('Command Routing', () => { + it('routes "start" subcommand correctly', () => { + const args = ['cliproxy', 'start']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'start'); + }); + + it('routes "restart" subcommand correctly', () => { + const args = ['cliproxy', 'restart']; + const subcommand = args[1]; + assert.strictEqual(subcommand, 'restart'); + }); + it('routes "stop" subcommand correctly', () => { const args = ['cliproxy', 'stop']; const subcommand = args[1]; @@ -504,7 +516,7 @@ describe('CLIProxy Command - Proxy Lifecycle', () => { }); it('handles unknown subcommand', () => { - const validSubcommands = ['stop', 'status', 'create', 'list', 'remove']; + const validSubcommands = ['start', 'restart', 'stop', 'status', 'create', 'list', 'remove']; const unknownCommand = 'invalid'; assert.strictEqual(validSubcommands.includes(unknownCommand), false); }); diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts new file mode 100644 index 00000000..3488ecfe --- /dev/null +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; +import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; + +let tempDir: string; +let originalCcsDir: string | undefined; + +function writeUnifiedConfig(localPort: number): void { + const configPath = path.join(tempDir, 'config.yaml'); + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: ${localPort} +`; + fs.writeFileSync(configPath, yaml, 'utf8'); +} + +describe('resolveLifecyclePort', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_DIR = tempDir; + }); + + afterEach(() => { + if (originalCcsDir !== undefined) { + process.env.CCS_DIR = originalCcsDir; + } else { + delete process.env.CCS_DIR; + } + + if (tempDir && fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('uses configured cliproxy_server.local.port', () => { + writeUnifiedConfig(9456); + expect(resolveLifecyclePort()).toBe(9456); + }); + + it('falls back to default port when configured local port is invalid', () => { + writeUnifiedConfig(70000); + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); + + it('falls back to default port when config file is missing', () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 92b9fa3b..b46fa317 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as http from 'http'; +import { spawn } from 'child_process'; import { getPidFromFile, writePidToFile, @@ -180,6 +182,37 @@ describe('isDaemonRunning', () => { const result = await isDaemonRunning(19999); expect(result).toBe(false); }); + + it('returns false when /health is 200 but service is not cursor-daemon', async () => { + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, service: 'not-cursor-daemon' })); + return; + } + + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + const result = await isDaemonRunning(address.port); + expect(result).toBe(false); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); }); describe('getDaemonStatus', () => { @@ -217,6 +250,36 @@ describe('stopDaemon', () => { const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); expect(fs.existsSync(pidFile)).toBe(false); }); + + it('does not terminate unrelated process from stale PID file', async () => { + const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { + detached: true, + stdio: 'ignore', + }); + unrelatedProcess.unref(); + + const unrelatedPid = unrelatedProcess.pid; + expect(unrelatedPid).toBeDefined(); + if (!unrelatedPid) { + throw new Error('Failed to spawn unrelated process'); + } + + writePidToFile(unrelatedPid); + + try { + const result = await stopDaemon(); + expect(result.success).toBe(true); + + // Unrelated process should still be alive. + expect(() => process.kill(unrelatedPid, 0)).not.toThrow(); + } finally { + try { + process.kill(unrelatedPid, 'SIGTERM'); + } catch { + // Process already exited. + } + } + }); }); describe('handleCursorCommand', () => {