Compare commits

...

1 Commits

Author SHA1 Message Date
Arvin Xu c7d13ad70d ♻️ refactor(hetero-agent): switch Claude Code CLI to sdk-ts entrypoint
The CLI's `--help` claims stream-json input "only works with --print",
but the official `@anthropic-ai/claude-agent-sdk` sets
`CLAUDE_CODE_ENTRYPOINT=sdk-ts` before spawn — this undocumented switch
unlocks non-`-p` stream-json IO and opens the control protocol side
channel (can_use_tool / hook_callback / interrupt / set_model /
set_permission_mode / get_context_usage / rewind_files / mcp_message).

Drop `-p` from CLAUDE_CODE_BASE_ARGS and inject the env at every spawn
site so future work (AskUserQuestion native, priority queue) can land
without re-flipping the entry mode. Externally visible behavior is
unchanged — the existing chat / tool / resume flows all keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:00:02 +08:00
7 changed files with 56 additions and 13 deletions
@@ -885,7 +885,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const proc = spawn(session.command, cliArgs, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...proxyEnv, ...session.env },
// `spawnPlan.env` carries driver-mandated overrides (e.g. Claude Code
// sets `CLAUDE_CODE_ENTRYPOINT=sdk-ts` to unlock non-`-p` stream-json
// + control protocol). `session.env` still wins so the user can
// override anything via per-session config.
env: { ...process.env, ...proxyEnv, ...spawnPlan.env, ...session.env },
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
@@ -27,6 +27,15 @@ describe('claudeCodeDriver', () => {
expect(args).not.toContain('--mcp-config');
});
it('omits -p and returns the sdk-ts entrypoint env', async () => {
// No `-p`: `CLAUDE_CODE_ENTRYPOINT=sdk-ts` unlocks non-`-p` stream-json
// and opens the control protocol channel. The controller merges this env
// into the spawned child's env before calling spawn().
const plan = await claudeCodeDriver.buildSpawnPlan(buildParams());
expect(plan.args).not.toContain('-p');
expect(plan.env?.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts');
});
it('appends --mcp-config <path> when mcpConfigPath is provided', async () => {
const { args } = await claudeCodeDriver.buildSpawnPlan(
buildParams({ mcpConfigPath: '/tmp/lobe-cc-mcp-op-1.json' }),
@@ -1,4 +1,4 @@
import { CLAUDE_CODE_BASE_ARGS } from '@lobechat/heterogeneous-agents/spawn';
import { CLAUDE_CODE_BASE_ARGS, CLAUDE_CODE_SPAWN_ENV } from '@lobechat/heterogeneous-agents/spawn';
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
@@ -34,6 +34,7 @@ export const claudeCodeDriver: HeterogeneousAgentDriver = {
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
...args,
],
env: { ...CLAUDE_CODE_SPAWN_ENV },
stdinPayload,
};
},
@@ -5,6 +5,12 @@ export interface HeterogeneousAgentImageAttachment {
export interface HeterogeneousAgentBuildPlan {
args: string[];
/**
* Extra environment variables the controller MUST merge into the spawned
* child's env. Used by Claude Code to set `CLAUDE_CODE_ENTRYPOINT=sdk-ts`,
* which unlocks non-`-p` stream-json + the control protocol side channel.
*/
env?: Record<string, string>;
stdinPayload?: string;
}
@@ -31,6 +31,7 @@ export {
export { JsonlStreamProcessor } from './jsonlProcessor';
export {
CLAUDE_CODE_BASE_ARGS,
CLAUDE_CODE_SPAWN_ENV,
spawnAgent,
type SpawnAgentHandle,
type SpawnAgentOptions,
@@ -106,10 +106,13 @@ describe('spawnAgent', () => {
expect(call.args).toContain('--input-format');
expect(call.args).toContain('--output-format');
expect(call.args.filter((a) => a === 'stream-json')).toHaveLength(2);
expect(call.args).toContain('-p');
// CC's built-in interactive Q&A is disabled at every spawn site so the
// model degrades to plain-text questioning instead of stalling on a
// synthetic "Answer questions?" tool_result.
// No `-p`: `CLAUDE_CODE_ENTRYPOINT=sdk-ts` unlocks non-`-p` stream-json
// and opens the control protocol channel.
expect(call.args).not.toContain('-p');
expect(call.options.env?.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-ts');
// CC's built-in AskUserQuestion stays disabled — LobeHub's intervention UI
// consumes the MCP-backed replacement, and surfacing both names lets the
// model pick the broken built-in.
const disallowedIdx = call.args.indexOf('--disallowedTools');
expect(disallowedIdx).toBeGreaterThan(-1);
expect(call.args[disallowedIdx + 1]).toBe('AskUserQuestion');
@@ -96,14 +96,20 @@ export interface SpawnAgentHandle {
* sandbox CLI may run as root and skip partials — so they're composed on top
* of this base.
*
* `AskUserQuestion` is disabled because CC's CLI self-injects an
* `is_error: "Answer questions?"` tool_result in `-p` mode before the host
* can surface the questions, so the model falls back to plain-text prompting
* anyway. Remove this once a local MCP-backed replacement is wired to
* LobeHub's intervention UI.
* No `-p`: the CLI's `--help` claims stream-json input "only works with --print",
* but `CLAUDE_CODE_ENTRYPOINT=sdk-ts` (set in `CLAUDE_CODE_SPAWN_ENV` below)
* unlocks non-`-p` stream-json and enables the control protocol side channel
* (can_use_tool / hook_callback / interrupt / set_model / set_permission_mode
* / get_context_usage / rewind_files / mcp_message). Keep `-p` off so future
* work can land AskUserQuestion native + priority queue semantics without
* re-flipping the entry mode.
*
* `AskUserQuestion` stays disabled: the LobeHub intervention UI consumes the
* MCP-backed replacement (`lobe_cc` / LOBE-8725), and surfacing both names to
* the model would let it pick the broken built-in. Remove this once the
* MCP bridge is fully replaced by the control-protocol-native flow.
*/
export const CLAUDE_CODE_BASE_ARGS = [
'-p',
'--input-format',
'stream-json',
'--output-format',
@@ -113,6 +119,18 @@ export const CLAUDE_CODE_BASE_ARGS = [
'AskUserQuestion',
] as const;
/**
* Environment overrides every Claude Code spawn site MUST merge into the
* child's env. `CLAUDE_CODE_ENTRYPOINT=sdk-ts` is an undocumented switch that
* the official `@anthropic-ai/claude-agent-sdk` sets before spawning — it
* tells the CLI to skip the `--print`-only check on stream-json IO and opens
* the control protocol channel. Without it, `CLAUDE_CODE_BASE_ARGS` (no `-p`)
* causes the CLI to reject stream-json input immediately.
*/
export const CLAUDE_CODE_SPAWN_ENV = {
CLAUDE_CODE_ENTRYPOINT: 'sdk-ts',
} as const;
// bypassPermissions is blocked when running as root (e.g. cloud sandbox).
// Fall back to acceptEdits + pre-approved tools so the agent can still run
// headlessly without interactive permission prompts.
@@ -230,10 +248,11 @@ export const spawnAgent = async (options: SpawnAgentOptions): Promise<SpawnAgent
});
const cwd = options.cwd || process.cwd();
const agentEnv = options.agentType === 'claude-code' ? CLAUDE_CODE_SPAWN_ENV : undefined;
const proc = spawn(command, args, {
cwd,
detached: process.platform !== 'win32',
env: { ...process.env, ...options.env },
env: { ...process.env, ...agentEnv, ...options.env },
stdio: ['pipe', 'pipe', 'pipe'],
});