From e5fe86f520f313632a8627ca70f551dd5bcb665b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 22:36:47 -0400 Subject: [PATCH] feat: add native Claude effort override --- docs/project-roadmap.md | 3 +- docs/system-architecture/target-adapters.md | 5 + scripts/run-test-bucket.js | 1 + src/ccs.ts | 72 +++++- src/commands/command-catalog.ts | 4 + src/delegation/delegation-handler.ts | 6 +- .../unit/commands/help-command-parity.test.ts | 7 + .../delegation/delegation-handler.test.ts | 15 ++ .../native-claude-effort-launch.test.ts | 212 ++++++++++++++++++ 9 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 tests/unit/targets/native-claude-effort-launch.test.ts diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 5f9265c8..5215f461 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-28 +Last Updated: 2026-04-30 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases. - **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account. - **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`. - **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index aa6349d6..5af341ee 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -233,6 +233,11 @@ export class ClaudeAdapter implements TargetAdapter { } ``` +Native Claude launches keep user arguments session-scoped. The launch layer validates and normalizes +`--effort low|medium|high|xhigh|max` before spawning Claude, then passes it through without writing +to Claude or CCS configuration. CLIProxy-backed Claude launches still treat `--effort` as the CCS +thinking alias handled by CLIProxy. + ### Credential Delivery **Method**: Environment variables diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 89952bef..b200eaed 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -33,6 +33,7 @@ const slowTests = [ 'tests/unit/targets/codex-settings-bridge-launch.test.ts', 'tests/unit/targets/droid-command-routing-integration.test.ts', 'tests/unit/targets/droid-config-manager.test.ts', + 'tests/unit/targets/native-claude-effort-launch.test.ts', 'tests/unit/targets/settings-profile-browser-launch.test.ts', 'tests/unit/targets/settings-profile-image-analysis-launch.test.ts', 'tests/unit/targets/settings-profile-websearch-launch.test.ts', diff --git a/src/ccs.ts b/src/ccs.ts index 46e51b54..0fde15ea 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -140,6 +140,8 @@ interface RuntimeReasoningResolution { const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); +const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const; +const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set(NATIVE_CLAUDE_EFFORT_LEVELS); function resolveCodexRuntimeConfigOverrides( target: ReturnType, @@ -241,6 +243,49 @@ function exitWithRuntimeReasoningFlagError( process.exit(1); } +function normalizeNativeClaudeEffortArgs(args: string[]): string[] { + const normalizedArgs: string[] = []; + const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', '); + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--effort') { + const rawValue = args[i + 1]; + if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(arg, value); + i++; + continue; + } + + if (arg.startsWith('--effort=')) { + const rawValue = arg.slice('--effort='.length); + if (!rawValue.trim()) { + throw new Error(`--effort requires a value: ${allowedValues}`); + } + const value = rawValue.toLowerCase(); + if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) { + throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`); + } + normalizedArgs.push(`--effort=${value}`); + continue; + } + + normalizedArgs.push(arg); + } + + return normalizedArgs; +} + +function shouldNormalizeNativeClaudeEffort(profileType: ProfileDetectionResult['type']): boolean { + return profileType === 'default' || profileType === 'account' || profileType === 'settings'; +} + // ========== Main Execution ========== interface ProfileError extends Error { @@ -780,6 +825,7 @@ async function main(): Promise { let targetRemainingArgs = remainingArgs; let runtimeReasoningOverride: string | number | undefined; + let nativeClaudeRemainingArgs = remainingArgs; if (resolvedTarget === 'droid') { try { const droidRoute = routeDroidCommandArgs(remainingArgs); @@ -838,17 +884,19 @@ async function main(): Promise { } throw error; } + } else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) { + nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs); } // Special case: headless delegation (-p/--prompt) // Keep existing behavior for Claude targets only; non-claude targets must continue // through normal adapter dispatch logic. if (args.includes('-p') || args.includes('--prompt')) { - const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy'; + const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type === 'settings'; if (shouldUseDelegation) { const { DelegationHandler } = await import('./delegation/delegation-handler'); const handler = new DelegationHandler(); - await handler.route(cleanArgs); + await handler.route([profile, ...nativeClaudeRemainingArgs]); return; } } @@ -1425,8 +1473,8 @@ async function main(): Promise { } const imageAnalysisArgs = imageAnalysisMcpReady - ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) - : remainingArgs; + ? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs; const browserArgs = browserRuntimeEnv ? appendBrowserToolArgs(imageAnalysisArgs) : imageAnalysisArgs; @@ -1527,8 +1575,16 @@ async function main(): Promise { CCS_WEBSEARCH_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs); - const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); + await maybeWarnAboutResumeLaneMismatch( + profileInfo.name, + instancePath, + nativeClaudeRemainingArgs + ); + const launchArgs = resolveNativeClaudeLaunchArgs( + nativeClaudeRemainingArgs, + 'account', + instancePath + ); execClaude(claudeCli, launchArgs, envVars); } else { // DEFAULT: No profile configured, use Claude's own defaults @@ -1626,7 +1682,9 @@ async function main(): Promise { } const launchArgs = resolveNativeClaudeLaunchArgs( - browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs, + browserRuntimeEnv + ? appendBrowserToolArgs(nativeClaudeRemainingArgs) + : nativeClaudeRemainingArgs, 'default', envVars.CLAUDE_CONFIG_DIR ); diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 2006422e..89d3e5c7 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -211,6 +211,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, + { + name: 'ccs --effort high "debug this"', + summary: 'Use a native Claude effort override for one session', + }, { name: 'ccs api create --preset glm', summary: 'Create a GLM-backed API profile' }, { name: 'ccs api create --preset anthropic --1m', diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index 998e247b..79870271 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -8,6 +8,8 @@ import { SettingsParser } from './settings-parser'; import { fail, warn } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; +const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']); + /** * Parse and validate a string flag value * @returns value if valid, undefined if invalid/missing @@ -158,11 +160,13 @@ export class DelegationHandler { continue; } - if (args[i] === '-p' || args[i] === '--prompt') { + if (PROFILE_FLAGS_WITH_VALUE.has(args[i])) { skipNext = true; continue; } + if (args[i].startsWith('--effort=')) continue; + if (!args[i].startsWith('-')) { return args[i]; } diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 100741df..bd7f6692 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -42,6 +42,13 @@ describe('help command parity', () => { expect(rendered.includes('ccs glmt')).toBe(false); }); + test('root help documents native Claude session effort override', async () => { + const rendered = await renderLines((writeLine) => handleHelpCommand(writeLine)); + + expect(rendered.includes('ccs --effort high "debug this"')).toBe(true); + expect(rendered.includes('Use a native Claude effort override for one session')).toBe(true); + }); + test('providers topic lists built-in OAuth provider shortcuts', async () => { const rendered = await renderLines((writeLine) => handleHelpRoute(['providers'], writeLine)); diff --git a/tests/unit/delegation/delegation-handler.test.ts b/tests/unit/delegation/delegation-handler.test.ts index c5d4e044..095a8e8f 100644 --- a/tests/unit/delegation/delegation-handler.test.ts +++ b/tests/unit/delegation/delegation-handler.test.ts @@ -184,6 +184,11 @@ describe('DelegationHandler', () => { expect(options.extraArgs).not.toContain('10'); expect(options.extraArgs).toContain('--unknown'); }); + + it('passes Claude effort override through to extraArgs', () => { + const options = handler._extractOptions(['glm', '--effort', 'low', '-p', 'test']); + expect(options.extraArgs).toEqual(['--effort', 'low']); + }); }); describe('_extractProfile', () => { @@ -201,5 +206,15 @@ describe('DelegationHandler', () => { const profile = handler._extractProfile(['-p', 'test', 'kimi']); expect(profile).toBe('kimi'); }); + + it('skips effort flag values before profile names', () => { + const profile = handler._extractProfile(['--effort', 'low', 'glm', '-p', 'test']); + expect(profile).toBe('glm'); + }); + + it('keeps leading profile names before effort flags', () => { + const profile = handler._extractProfile(['glm', '--effort', 'low', '-p', 'test']); + expect(profile).toBe('glm'); + }); }); }); diff --git a/tests/unit/targets/native-claude-effort-launch.test.ts b/tests/unit/targets/native-claude-effort-launch.test.ts new file mode 100644 index 00000000..c2735d97 --- /dev/null +++ b/tests/unit/targets/native-claude-effort-launch.test.ts @@ -0,0 +1,212 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 10000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +function readLoggedArgs(logPath: string): string[] { + return fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean); +} + +function writeFakeCliproxyBinary(ccsDir: string): void { + const binDir = path.join(ccsDir, 'cliproxy', 'bin', 'original'); + const binaryPath = path.join( + binDir, + process.platform === 'win32' ? 'cli-proxy-api.exe' : 'cli-proxy-api' + ); + + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(binaryPath, '#!/bin/sh\nexit 0\n', { encoding: 'utf8', mode: 0o755 }); + fs.chmodSync(binaryPath, 0o755); +} + +describe('native Claude effort launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') return; + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-native-effort-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'directapi.settings.json'); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'profiles:', + ' directapi:', + ' type: api', + ` settings: ${settingsPath}`, + 'accounts:', + ' work:', + ' created: "2026-05-01T00:00:00.000Z"', + ' last_used: null', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + 'websearch:', + ' enabled: false', + 'image_analysis:', + ' enabled: false', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_API_KEY: 'test-api-key', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\\n" "$@" > "${claudeArgsLogPath}" +{ + printf "claudeConfigDir=%s\\n" "$CLAUDE_CONFIG_DIR" + printf "profileType=%s\\n" "$CCS_PROFILE_TYPE" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + CCS_SKIP_PREFLIGHT: '1', + }; + }); + + afterEach(() => { + if (process.platform === 'win32') return; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('passes session effort to default native Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['--effort', 'High', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(readLoggedArgs(claudeArgsLogPath)).toEqual(['--effort', 'high', 'smoke']); + expect(fs.readFileSync(claudeEnvLogPath, 'utf8')).toContain('profileType=default'); + }); + + it('keeps default headless native Claude launches out of delegation parsing', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['--effort=low', '-p', 'quick check'], baseEnv); + + expect(result.status).toBe(0); + expect(readLoggedArgs(claudeArgsLogPath)).toEqual(['--effort=low', '-p', 'quick check']); + }); + + it('passes session effort through account-isolated native Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['work', '--effort', 'xhigh', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(readLoggedArgs(claudeArgsLogPath)).toEqual(['--effort', 'xhigh', 'smoke']); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('profileType=account'); + expect(launchedEnv).toContain(path.join(tmpHome, '.ccs', 'instances', 'work')); + }); + + it('passes session effort through native settings-profile Claude launches', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['directapi', '--effort', 'max', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(readLoggedArgs(claudeArgsLogPath).slice(0, 5)).toEqual([ + '--settings', + settingsPath, + '--effort', + 'max', + 'smoke', + ]); + }); + + it('rejects invalid native Claude effort before spawning Claude', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['--effort', 'turbo', 'smoke'], baseEnv); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Invalid --effort value: turbo'); + expect(result.stderr).toContain('low, medium, high, xhigh, max'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); + + it('rejects missing native Claude effort before spawning Claude', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['--effort'], baseEnv); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('--effort requires a value'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); + + it('keeps CLIProxy-backed Claude effort on the CLIProxy thinking path', () => { + if (process.platform === 'win32') return; + + writeFakeCliproxyBinary(ccsDir); + + const result = runCcs(['gemini', '--effort', 'minimal', '--accounts'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('No accounts registered'); + expect(result.stderr).toContain('Continuing as alias of `--thinking`'); + expect(result.stderr).not.toContain('Invalid --effort value'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); + }); +});