mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix: support Claude subcommands and background sessions
Squash merge PR #1240.\n\nValidated locally from the PR head because trusted-author GitHub CI was skipped for this fork PR:\n- bun run validate\n- bun run build:all\n- bun run test:all\n- CCS_E2E_SKIP_BUILD=1 bun run test:e2e
This commit is contained in:
1 parent
e87b627764
commit
35b6210cd2
7 files changed
+340
-113
No files matched your search
@@ -26,6 +26,8 @@ import { setupCleanupHandlers } from './session-bridge';
|
|||||||
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
|
||||||
import {
|
import {
|
||||||
isClaudeSubcommandInvocation,
|
isClaudeSubcommandInvocation,
|
||||||
|
stripClaudeCodeFeatureBlockingEnv,
|
||||||
|
stripClaudeSubcommandSessionArgs,
|
||||||
stripSubcommandBlockingEnv,
|
stripSubcommandBlockingEnv,
|
||||||
} from '../../utils/claude-subcommand-detector';
|
} from '../../utils/claude-subcommand-detector';
|
||||||
|
|
||||||
@@ -94,18 +96,22 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
||||||
: getProviderSettingsPath(provider);
|
: getProviderSettingsPath(provider);
|
||||||
|
|
||||||
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
|
||||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
|
||||||
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
|
||||||
: claudeArgs;
|
|
||||||
const browserArgs = browserRuntimeEnv
|
|
||||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
|
||||||
: imageAnalysisArgs;
|
|
||||||
// Claude subcommands (agents, doctor, mcp, ...) don't accept `--settings` —
|
// Claude subcommands (agents, doctor, mcp, ...) don't accept `--settings` —
|
||||||
// its presence flips `agents` into list mode instead of opening the
|
// its presence flips `agents` into list mode instead of opening the
|
||||||
// interactive agent view. Provider routing still flows via env vars.
|
// interactive agent view. Provider routing still flows via env vars.
|
||||||
// Issue #1218.
|
// Issue #1218.
|
||||||
const isSubcommand = isClaudeSubcommandInvocation(claudeArgs);
|
const isSubcommand = isClaudeSubcommandInvocation(claudeArgs);
|
||||||
|
const claudeSessionArgs = isSubcommand
|
||||||
|
? stripClaudeSubcommandSessionArgs(claudeArgs)
|
||||||
|
: claudeArgs;
|
||||||
|
|
||||||
|
// Assemble final args: image analysis tools → browser tools → web search tools → settings
|
||||||
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||||
|
? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs)
|
||||||
|
: claudeSessionArgs;
|
||||||
|
const browserArgs = browserRuntimeEnv
|
||||||
|
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||||
|
: imageAnalysisArgs;
|
||||||
const launchArgs = isSubcommand
|
const launchArgs = isSubcommand
|
||||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
||||||
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||||
@@ -119,12 +125,11 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
settingsPath,
|
settingsPath,
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
});
|
});
|
||||||
|
const baseTracedEnv = stripClaudeCodeFeatureBlockingEnv({ ...env, ...traceEnv });
|
||||||
// Strip telemetry-disable env vars for subcommands; otherwise Claude's
|
// Strip telemetry-disable env vars for subcommands; otherwise Claude's
|
||||||
// `agents`/`mcp`/... TUIs silently fall back to non-interactive list mode.
|
// `agents`/`mcp`/... TUIs silently fall back to non-interactive list mode.
|
||||||
// Issue #1218.
|
// Issue #1218.
|
||||||
const tracedEnv = isSubcommand
|
const tracedEnv = isSubcommand ? stripSubcommandBlockingEnv(baseTracedEnv) : baseTracedEnv;
|
||||||
? stripSubcommandBlockingEnv({ ...env, ...traceEnv })
|
|
||||||
: { ...env, ...traceEnv };
|
|
||||||
|
|
||||||
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
|
||||||
let claude: ChildProcess;
|
let claude: ChildProcess;
|
||||||
@@ -158,6 +163,9 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasSessionProxy = Boolean(codexReasoningProxy || toolSanitizationProxy || httpsTunnel);
|
||||||
|
const backgroundKeepAliveBaseUrl = hasSessionProxy ? tracedEnv.ANTHROPIC_BASE_URL : undefined;
|
||||||
|
|
||||||
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
|
||||||
setupCleanupHandlers(
|
setupCleanupHandlers(
|
||||||
claude,
|
claude,
|
||||||
@@ -166,7 +174,8 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
|
|||||||
codexReasoningProxy,
|
codexReasoningProxy,
|
||||||
toolSanitizationProxy,
|
toolSanitizationProxy,
|
||||||
httpsTunnel,
|
httpsTunnel,
|
||||||
verbose
|
verbose,
|
||||||
|
{ backgroundKeepAliveBaseUrl }
|
||||||
);
|
);
|
||||||
|
|
||||||
return claude;
|
return claude;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* - Startup lock coordination
|
* - Startup lock coordination
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess, execFileSync } from 'child_process';
|
||||||
import { info, warn } from '../../utils/ui';
|
import { info, warn } from '../../utils/ui';
|
||||||
import { getInstalledCliproxyVersion } from '../binary-manager';
|
import { getInstalledCliproxyVersion } from '../binary-manager';
|
||||||
import { CLIProxyBackend } from '../types';
|
import { CLIProxyBackend } from '../types';
|
||||||
@@ -33,6 +33,70 @@ export interface ProxySessionResult {
|
|||||||
shouldSpawn: boolean;
|
shouldSpawn: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionProxyKeepAliveOptions {
|
||||||
|
backgroundKeepAliveBaseUrl?: string;
|
||||||
|
keepAlivePollIntervalMs?: number;
|
||||||
|
hasBackgroundWorkerUsingBaseUrl?: (baseUrl: string) => 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
|
* Check for existing proxy and handle version mismatch, or determine if new spawn needed
|
||||||
*/
|
*/
|
||||||
@@ -180,7 +244,8 @@ export function setupCleanupHandlers(
|
|||||||
codexReasoningProxy: unknown,
|
codexReasoningProxy: unknown,
|
||||||
toolSanitizationProxy: unknown,
|
toolSanitizationProxy: unknown,
|
||||||
httpsTunnel: unknown,
|
httpsTunnel: unknown,
|
||||||
verbose: boolean
|
verbose: boolean,
|
||||||
|
keepAliveOptions: SessionProxyKeepAliveOptions = {}
|
||||||
): void {
|
): void {
|
||||||
const log = (msg: string) => {
|
const log = (msg: string) => {
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
@@ -188,66 +253,67 @@ export function setupCleanupHandlers(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
let resourcesStopped = false;
|
||||||
stopQuotaMonitor();
|
|
||||||
log('Parent signal received, cleaning up');
|
|
||||||
|
|
||||||
if (
|
const stopResource = (resource: unknown) => {
|
||||||
codexReasoningProxy &&
|
if (resource && typeof resource === 'object' && 'stop' in resource) {
|
||||||
typeof codexReasoningProxy === 'object' &&
|
(resource as { stop: () => void }).stop();
|
||||||
'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);
|
|
||||||
}
|
|
||||||
claude.kill('SIGTERM');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
claude.on('exit', (code, signal) => {
|
const stopSessionResources = () => {
|
||||||
|
if (resourcesStopped) return;
|
||||||
|
resourcesStopped = true;
|
||||||
stopQuotaMonitor();
|
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) {
|
if (sessionId) {
|
||||||
unregisterSession(sessionId, sessionPort);
|
unregisterSession(sessionId, sessionPort);
|
||||||
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
|
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) {
|
if (signal) {
|
||||||
process.kill(process.pid, signal as NodeJS.Signals);
|
process.kill(process.pid, signal as NodeJS.Signals);
|
||||||
@@ -257,33 +323,8 @@ export function setupCleanupHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
claude.on('error', (error) => {
|
claude.on('error', (error) => {
|
||||||
stopQuotaMonitor();
|
|
||||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||||
|
stopSessionResources();
|
||||||
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);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ import {
|
|||||||
normalizeDeprecatedGlmtEnv,
|
normalizeDeprecatedGlmtEnv,
|
||||||
} from '../../utils/glmt-deprecation';
|
} from '../../utils/glmt-deprecation';
|
||||||
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
|
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
|
||||||
import { isClaudeSubcommandInvocation } from '../../utils/claude-subcommand-detector';
|
import {
|
||||||
|
isClaudeSubcommandInvocation,
|
||||||
|
stripClaudeSubcommandSessionArgs,
|
||||||
|
} from '../../utils/claude-subcommand-detector';
|
||||||
import {
|
import {
|
||||||
resolveDroidProvider,
|
resolveDroidProvider,
|
||||||
evaluateTargetRuntimeCompatibility,
|
evaluateTargetRuntimeCompatibility,
|
||||||
@@ -371,8 +374,11 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
|||||||
// Claude subcommands reject `--settings` (it flips `agents` to list mode).
|
// Claude subcommands reject `--settings` (it flips `agents` to list mode).
|
||||||
// Routing env vars still flow via proxyEnv. Issue #1218.
|
// Routing env vars still flow via proxyEnv. Issue #1218.
|
||||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||||
|
const subcommandArgs = isSubcommand
|
||||||
|
? stripClaudeSubcommandSessionArgs(browserArgs)
|
||||||
|
: browserArgs;
|
||||||
const launchArgs = isSubcommand
|
const launchArgs = isSubcommand
|
||||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
|
||||||
: [
|
: [
|
||||||
'--settings',
|
'--settings',
|
||||||
launchSettings.settingsPath,
|
launchSettings.settingsPath,
|
||||||
@@ -393,8 +399,9 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
|||||||
// Skip `--settings` for Claude subcommands so the interactive agent view
|
// Skip `--settings` for Claude subcommands so the interactive agent view
|
||||||
// works; env vars from the settings file still flow via envVars. Issue #1218.
|
// works; env vars from the settings file still flow via envVars. Issue #1218.
|
||||||
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
|
||||||
|
const subcommandArgs = isSubcommand ? stripClaudeSubcommandSessionArgs(browserArgs) : browserArgs;
|
||||||
const launchArgs = isSubcommand
|
const launchArgs = isSubcommand
|
||||||
? appendThirdPartyWebSearchToolArgs(browserArgs)
|
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
|
||||||
: ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
: ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'ccs.settings-profile',
|
launcher: 'ccs.settings-profile',
|
||||||
|
|||||||
@@ -80,9 +80,20 @@ const VALUE_TAKING_FLAGS = new Set<string>([
|
|||||||
'--setting-sources',
|
'--setting-sources',
|
||||||
'--settings',
|
'--settings',
|
||||||
'--system-prompt',
|
'--system-prompt',
|
||||||
|
'--teammate-mode',
|
||||||
'--tools',
|
'--tools',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const SUBCOMMAND_SESSION_ONLY_FLAGS = new Set<string>([
|
||||||
|
'--allow-dangerously-skip-permissions',
|
||||||
|
'--dangerously-skip-permissions',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set<string>([
|
||||||
|
'--permission-mode',
|
||||||
|
'--teammate-mode',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when `args` look like a Claude subcommand invocation.
|
* Returns true when `args` look like a Claude subcommand invocation.
|
||||||
*
|
*
|
||||||
@@ -119,27 +130,49 @@ export function isClaudeSubcommandInvocation(args: readonly string[]): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] {
|
||||||
* Claude env vars that disable interactive subcommand TUIs (e.g. the new
|
if (!isClaudeSubcommandInvocation(args)) {
|
||||||
* `claude agents` agent view) when set. CCS injects these as defaults to
|
return [...args];
|
||||||
* silence telemetry/bug-report prompts in normal sessions, but they trip
|
}
|
||||||
* subcommands into non-interactive list mode. Issue #1218.
|
|
||||||
*
|
const out: string[] = [];
|
||||||
* Strip only for confirmed Claude subcommand invocations — keep the user's
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
* telemetry preference intact for everything else.
|
const arg = args[i];
|
||||||
*/
|
if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) {
|
||||||
const SUBCOMMAND_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const;
|
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
|
* Claude Code treats DISABLE_TELEMETRY as a feature kill switch for agent-view
|
||||||
* removed. Caller is responsible for only invoking this when args are a
|
* and background-session surfaces, not only as telemetry preference.
|
||||||
* Claude subcommand invocation.
|
|
||||||
*/
|
*/
|
||||||
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 = {};
|
const out: NodeJS.ProcessEnv = {};
|
||||||
for (const [key, value] of Object.entries(env)) {
|
for (const [key, value] of Object.entries(env)) {
|
||||||
if (
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -147,3 +180,12 @@ export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.Proce
|
|||||||
}
|
}
|
||||||
return out;
|
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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { getWebSearchHookEnv } from './websearch-manager';
|
|||||||
import { wireChildProcessSignals } from './signal-forwarder';
|
import { wireChildProcessSignals } from './signal-forwarder';
|
||||||
import {
|
import {
|
||||||
isClaudeSubcommandInvocation,
|
isClaudeSubcommandInvocation,
|
||||||
|
stripClaudeCodeFeatureBlockingEnv,
|
||||||
|
stripClaudeSubcommandSessionArgs,
|
||||||
stripSubcommandBlockingEnv,
|
stripSubcommandBlockingEnv,
|
||||||
} from './claude-subcommand-detector';
|
} from './claude-subcommand-detector';
|
||||||
|
|
||||||
@@ -288,15 +290,19 @@ export function execClaude(
|
|||||||
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
|
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
|
||||||
: mergedEnv;
|
: mergedEnv;
|
||||||
|
|
||||||
|
const effectiveArgs = isClaudeSubcommandInvocation(args)
|
||||||
|
? stripClaudeSubcommandSessionArgs(args)
|
||||||
|
: args;
|
||||||
|
|
||||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||||
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
// (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
|
// For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip
|
||||||
// telemetry-disable env vars that cause upstream Claude Code to fall back
|
// telemetry-disable env vars that cause upstream Claude Code to fall back
|
||||||
// to non-interactive list mode instead of opening the subcommand TUI.
|
// to non-interactive list mode instead of opening the subcommand TUI.
|
||||||
// Issue #1218.
|
// Issue #1218.
|
||||||
if (isClaudeSubcommandInvocation(args)) {
|
if (isClaudeSubcommandInvocation(effectiveArgs)) {
|
||||||
env = stripSubcommandBlockingEnv(env);
|
env = stripSubcommandBlockingEnv(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +322,7 @@ export function execClaude(
|
|||||||
if (isPowerShellScript) {
|
if (isPowerShellScript) {
|
||||||
child = spawn(
|
child = spawn(
|
||||||
'powershell.exe',
|
'powershell.exe',
|
||||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...effectiveArgs],
|
||||||
{
|
{
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -325,7 +331,7 @@ export function execClaude(
|
|||||||
);
|
);
|
||||||
} else if (needsShell) {
|
} else if (needsShell) {
|
||||||
// When shell needed: concatenate into string to avoid DEP0190 warning
|
// 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, {
|
child = spawn(cmdString, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
@@ -334,7 +340,7 @@ export function execClaude(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// When no shell needed: use array form (faster, no shell overhead)
|
// When no shell needed: use array form (faster, no shell overhead)
|
||||||
child = spawn(claudeCli, args, {
|
child = spawn(claudeCli, effectiveArgs, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
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 { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args';
|
||||||
import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args';
|
import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args';
|
||||||
import { appendBrowserToolArgs } from '../../../src/utils/browser/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', () => {
|
it('skips past value-taking flags before the positional', () => {
|
||||||
expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true);
|
expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true);
|
||||||
expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).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', () => {
|
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', () => {
|
describe('subcommand passthrough — injectors short-circuit', () => {
|
||||||
it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => {
|
it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => {
|
||||||
expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']);
|
expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']);
|
||||||
|
|||||||
Reference in new issue
Block a user