diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index 04766e21..66663f05 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -26,6 +26,8 @@ import { setupCleanupHandlers } from './session-bridge'; import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; import { isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, stripSubcommandBlockingEnv, } from '../../utils/claude-subcommand-detector'; @@ -94,18 +96,22 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise boolean; +} + +export interface ShouldKeepSessionProxiesAliveOptions { + code: number | null; + signal: NodeJS.Signals | null; + backgroundKeepAliveBaseUrl?: string; + hasBackgroundWorkerUsingBaseUrl: (baseUrl: string) => boolean; +} + +const CLAUDE_BG_WORKER_ARGS = ['--bg-spare', '--bg-pty-host'] as const; + +export function hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( + processList: string, + baseUrl: string +): boolean { + const envNeedle = `ANTHROPIC_BASE_URL=${baseUrl}`; + return processList + .split(/\r?\n/) + .some( + (line) => line.includes(envNeedle) && CLAUDE_BG_WORKER_ARGS.some((arg) => line.includes(arg)) + ); +} + +export function hasClaudeBackgroundWorkerUsingBaseUrl(baseUrl: string): boolean { + if (process.platform === 'win32') { + return false; + } + + for (const args of [['auxEww'], ['auxeww'], ['eww', '-axo', 'pid=,command=']]) { + try { + const processList = execFileSync('ps', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)) { + return true; + } + } catch { + continue; + } + } + + return false; +} + +export function shouldKeepSessionProxiesAlive( + options: ShouldKeepSessionProxiesAliveOptions +): boolean { + if (options.code !== 0 || options.signal) { + return false; + } + + const baseUrl = options.backgroundKeepAliveBaseUrl; + if (!baseUrl) { + return false; + } + + return options.hasBackgroundWorkerUsingBaseUrl(baseUrl); +} + /** * Check for existing proxy and handle version mismatch, or determine if new spawn needed */ @@ -180,7 +244,8 @@ export function setupCleanupHandlers( codexReasoningProxy: unknown, toolSanitizationProxy: unknown, httpsTunnel: unknown, - verbose: boolean + verbose: boolean, + keepAliveOptions: SessionProxyKeepAliveOptions = {} ): void { const log = (msg: string) => { if (verbose) { @@ -188,66 +253,67 @@ export function setupCleanupHandlers( } }; - const cleanup = () => { - stopQuotaMonitor(); - log('Parent signal received, cleaning up'); + let resourcesStopped = false; - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); + const stopResource = (resource: unknown) => { + if (resource && typeof resource === 'object' && 'stop' in resource) { + (resource as { stop: () => void }).stop(); } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } - claude.kill('SIGTERM'); }; - claude.on('exit', (code, signal) => { + const stopSessionResources = () => { + if (resourcesStopped) return; + resourcesStopped = true; stopQuotaMonitor(); - log(`Claude exited: code=${code}, signal=${signal}`); + stopResource(codexReasoningProxy); + stopResource(toolSanitizationProxy); + stopResource(httpsTunnel); - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); - } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister this session (proxy keeps running for persistence) - only for local mode if (sessionId) { unregisterSession(sessionId, sessionPort); log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); } + }; + + const backgroundProbe = + keepAliveOptions.hasBackgroundWorkerUsingBaseUrl ?? hasClaudeBackgroundWorkerUsingBaseUrl; + + const startKeepAliveWatcher = (baseUrl: string) => { + const timer = setInterval(() => { + if (backgroundProbe(baseUrl)) return; + log('No Claude bg worker is using the session proxy; cleaning up'); + stopSessionResources(); + process.exit(0); + }, keepAliveOptions.keepAlivePollIntervalMs ?? 30000); + timer.unref(); + }; + + const cleanup = () => { + log('Parent signal received, cleaning up'); + stopSessionResources(); + claude.kill('SIGTERM'); + }; + + claude.on('exit', (code, signal) => { + log(`Claude exited: code=${code}, signal=${signal}`); + const backgroundKeepAliveBaseUrl = keepAliveOptions.backgroundKeepAliveBaseUrl; + + if ( + shouldKeepSessionProxiesAlive({ + code, + signal, + backgroundKeepAliveBaseUrl, + hasBackgroundWorkerUsingBaseUrl: backgroundProbe, + }) && + backgroundKeepAliveBaseUrl + ) { + stopQuotaMonitor(); + log(`Keeping session proxy alive for Claude bg worker: ${backgroundKeepAliveBaseUrl}`); + startKeepAliveWatcher(backgroundKeepAliveBaseUrl); + return; + } + + stopSessionResources(); if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -257,33 +323,8 @@ export function setupCleanupHandlers( }); claude.on('error', (error) => { - stopQuotaMonitor(); console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`)); - - if ( - codexReasoningProxy && - typeof codexReasoningProxy === 'object' && - 'stop' in codexReasoningProxy - ) { - (codexReasoningProxy as { stop: () => void }).stop(); - } - - if ( - toolSanitizationProxy && - typeof toolSanitizationProxy === 'object' && - 'stop' in toolSanitizationProxy - ) { - (toolSanitizationProxy as { stop: () => void }).stop(); - } - - if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) { - (httpsTunnel as { stop: () => void }).stop(); - } - - // Unregister session, proxy keeps running (local mode only) - if (sessionId) { - unregisterSession(sessionId, sessionPort); - } + stopSessionResources(); process.exit(1); }); diff --git a/src/dispatcher/flows/settings-flow.ts b/src/dispatcher/flows/settings-flow.ts index f80a1c15..a82b9273 100644 --- a/src/dispatcher/flows/settings-flow.ts +++ b/src/dispatcher/flows/settings-flow.ts @@ -44,7 +44,10 @@ import { normalizeDeprecatedGlmtEnv, } from '../../utils/glmt-deprecation'; import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings'; -import { isClaudeSubcommandInvocation } from '../../utils/claude-subcommand-detector'; +import { + isClaudeSubcommandInvocation, + stripClaudeSubcommandSessionArgs, +} from '../../utils/claude-subcommand-detector'; import { resolveDroidProvider, evaluateTargetRuntimeCompatibility, @@ -371,8 +374,11 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise([ '--setting-sources', '--settings', '--system-prompt', + '--teammate-mode', '--tools', ]); +const SUBCOMMAND_SESSION_ONLY_FLAGS = new Set([ + '--allow-dangerously-skip-permissions', + '--dangerously-skip-permissions', +]); + +const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set([ + '--permission-mode', + '--teammate-mode', +]); + /** * Returns true when `args` look like a Claude subcommand invocation. * @@ -119,27 +130,49 @@ export function isClaudeSubcommandInvocation(args: readonly string[]): boolean { return false; } -/** - * Claude env vars that disable interactive subcommand TUIs (e.g. the new - * `claude agents` agent view) when set. CCS injects these as defaults to - * silence telemetry/bug-report prompts in normal sessions, but they trip - * subcommands into non-interactive list mode. Issue #1218. - * - * Strip only for confirmed Claude subcommand invocations — keep the user's - * telemetry preference intact for everything else. - */ -const SUBCOMMAND_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const; +export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] { + if (!isClaudeSubcommandInvocation(args)) { + return [...args]; + } + + const out: string[] = []; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) { + continue; + } + + if (arg.startsWith('--permission-mode=') || arg.startsWith('--teammate-mode=')) { + continue; + } + + if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg)) { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith('-')) { + i += 1; + } + continue; + } + + out.push(arg); + } + + return out; +} /** - * Return a shallow copy of `env` with subcommand-blocking telemetry vars - * removed. Caller is responsible for only invoking this when args are a - * Claude subcommand invocation. + * Claude Code treats DISABLE_TELEMETRY as a feature kill switch for agent-view + * and background-session surfaces, not only as telemetry preference. */ -export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +const CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const; + +export function stripClaudeCodeFeatureBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const out: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(env)) { if ( - SUBCOMMAND_BLOCKING_ENV_KEYS.includes(key as (typeof SUBCOMMAND_BLOCKING_ENV_KEYS)[number]) + CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS.includes( + key as (typeof CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS)[number] + ) ) { continue; } @@ -147,3 +180,12 @@ export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.Proce } return out; } + +/** + * Return a shallow copy of `env` with subcommand-blocking telemetry vars + * removed. Caller is responsible for only invoking this when args are a + * Claude subcommand invocation. + */ +export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return stripClaudeCodeFeatureBlockingEnv(env); +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 8336938d..69c94916 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -11,6 +11,8 @@ import { getWebSearchHookEnv } from './websearch-manager'; import { wireChildProcessSignals } from './signal-forwarder'; import { isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, stripSubcommandBlockingEnv, } from './claude-subcommand-detector'; @@ -288,15 +290,19 @@ export function execClaude( ? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined) : mergedEnv; + const effectiveArgs = isClaudeSubcommandInvocation(args) + ? stripClaudeSubcommandSessionArgs(args) + : args; + // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) - let env = stripClaudeCodeEnv(effectiveMergedEnv); + let env = stripClaudeCodeFeatureBlockingEnv(stripClaudeCodeEnv(effectiveMergedEnv)); // For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip // telemetry-disable env vars that cause upstream Claude Code to fall back // to non-interactive list mode instead of opening the subcommand TUI. // Issue #1218. - if (isClaudeSubcommandInvocation(args)) { + if (isClaudeSubcommandInvocation(effectiveArgs)) { env = stripSubcommandBlockingEnv(env); } @@ -316,7 +322,7 @@ export function execClaude( if (isPowerShellScript) { child = spawn( 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args], + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...effectiveArgs], { stdio: 'inherit', windowsHide: true, @@ -325,7 +331,7 @@ export function execClaude( ); } else if (needsShell) { // When shell needed: concatenate into string to avoid DEP0190 warning - const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' '); + const cmdString = [claudeCli, ...effectiveArgs].map(escapeShellArg).join(' '); child = spawn(cmdString, { stdio: 'inherit', windowsHide: true, @@ -334,7 +340,7 @@ export function execClaude( }); } else { // When no shell needed: use array form (faster, no shell overhead) - child = spawn(claudeCli, args, { + child = spawn(claudeCli, effectiveArgs, { stdio: 'inherit', windowsHide: true, env, diff --git a/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts b/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts new file mode 100644 index 00000000..f911be55 --- /dev/null +++ b/tests/unit/cliproxy/session-bridge-bg-keepalive.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'bun:test'; +import { + hasClaudeBackgroundWorkerUsingBaseUrlInProcessList, + shouldKeepSessionProxiesAlive, +} from '../../../src/cliproxy/executor/session-bridge'; + +describe('session bridge background proxy keepalive', () => { + it('keeps per-session proxies alive after a clean Claude exit when a bg worker inherited the base URL', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: 0, + signal: null, + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => true, + }) + ).toBe(true); + }); + + it('cleans up per-session proxies when no bg worker inherited the base URL', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: 0, + signal: null, + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => false, + }) + ).toBe(false); + }); + + it('cleans up per-session proxies for signaled Claude exits', () => { + expect( + shouldKeepSessionProxiesAlive({ + code: null, + signal: 'SIGTERM', + backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex', + hasBackgroundWorkerUsingBaseUrl: () => true, + }) + ).toBe(false); + }); + + it('detects Claude bg workers that inherited the exact base URL', () => { + const baseUrl = 'http://127.0.0.1:64314/api/provider/codex'; + const processList = ` +123 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=${baseUrl} ANTHROPIC_AUTH_TOKEN=ccs-internal-managed +456 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=http://127.0.0.1:62075/api/provider/codex +789 /opt/claude/claude.exe --print ANTHROPIC_BASE_URL=${baseUrl} +`; + + expect(hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)).toBe(true); + expect( + hasClaudeBackgroundWorkerUsingBaseUrlInProcessList( + processList, + 'http://127.0.0.1:65535/api/provider/codex' + ) + ).toBe(false); + }); +}); diff --git a/tests/unit/utils/claude-subcommand-detector.test.ts b/tests/unit/utils/claude-subcommand-detector.test.ts index de430305..866e7604 100644 --- a/tests/unit/utils/claude-subcommand-detector.test.ts +++ b/tests/unit/utils/claude-subcommand-detector.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { isClaudeSubcommandInvocation } from '../../../src/utils/claude-subcommand-detector'; +import { + isClaudeSubcommandInvocation, + stripClaudeCodeFeatureBlockingEnv, + stripClaudeSubcommandSessionArgs, +} from '../../../src/utils/claude-subcommand-detector'; import { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args'; import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args'; import { appendBrowserToolArgs } from '../../../src/utils/browser/claude-tool-args'; @@ -36,6 +40,14 @@ describe('isClaudeSubcommandInvocation', () => { it('skips past value-taking flags before the positional', () => { expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true); expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).toBe(true); + expect( + isClaudeSubcommandInvocation([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'agents', + ]) + ).toBe(true); }); it('does not treat a flag value matching a subcommand name as a subcommand', () => { @@ -57,6 +69,59 @@ describe('isClaudeSubcommandInvocation', () => { }); }); +describe('stripClaudeCodeFeatureBlockingEnv', () => { + it('removes telemetry disable env that blocks Claude Code background features', () => { + const env = stripClaudeCodeFeatureBlockingEnv({ + DISABLE_TELEMETRY: '1', + DISABLE_BUG_COMMAND: '1', + DISABLE_ERROR_REPORTING: '1', + ANTHROPIC_MODEL: 'gpt-5.5', + }); + + expect(env.DISABLE_TELEMETRY).toBeUndefined(); + expect(env.DISABLE_BUG_COMMAND).toBe('1'); + expect(env.DISABLE_ERROR_REPORTING).toBe('1'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5'); + }); +}); + +describe('stripClaudeSubcommandSessionArgs', () => { + it('removes session-only flags before a Claude subcommand', () => { + expect( + stripClaudeSubcommandSessionArgs([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'agents', + ]) + ).toEqual(['agents']); + }); + + it('removes session-only flags after a Claude subcommand while preserving subcommand flags', () => { + expect( + stripClaudeSubcommandSessionArgs([ + 'agents', + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + '--setting-sources', + 'user', + ]) + ).toEqual(['agents', '--setting-sources', 'user']); + }); + + it('leaves non-subcommand interactive launches unchanged', () => { + expect( + stripClaudeSubcommandSessionArgs([ + '--dangerously-skip-permissions', + '--teammate-mode', + 'in-process', + 'fix the bug', + ]) + ).toEqual(['--dangerously-skip-permissions', '--teammate-mode', 'in-process', 'fix the bug']); + }); +}); + describe('subcommand passthrough — injectors short-circuit', () => { it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => { expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']);