Files
superwork/vscode/src/cc/spawnClaude.ts
T

504 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with
* `--output-format json`. We rely on `claude` being available on PATH;
* callers should surface the install-hint error message to the user when
* ENOENT is raised.
*
* Two execution paths share the same output-parsing logic:
*
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json`
* via `spawn` with stdin ignored so the CLI does not wait for piped input.
*
* - **With images**: switch to `claude -p --input-format stream-json
* --output-format json` via `spawn`, then feed a single NDJSON line on
* stdin containing the user message with text + image content blocks
* (Anthropic Messages API shape). The official docs state single-message
* input does NOT support image attachments — stream-json is the only
* supported path. See:
* https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode
*/
import { spawn } from 'node:child_process'
import * as path from 'node:path'
import { resolveClaudeBin } from './claudeBin'
const DEFAULT_TIMEOUT_MS = 300_000
const MAX_BUFFER_BYTES = 10 * 1024 * 1024
const EXIT_DIAGNOSTIC_MAX_CHARS = 2000
export interface ClaudeImage {
/** e.g. "image/png", "image/jpeg", "image/webp", "image/gif" */
mediaType: string
/** Raw base64 (no `data:...;base64,` prefix) */
base64: string
}
export interface ClaudeResult {
sessionId: string
resultText: string
rawJson: string
}
export class ClaudeError extends Error {
constructor(message: string, public readonly stderr: string) {
super(message)
this.name = 'ClaudeError'
}
}
export class ClaudeTimeoutError extends ClaudeError {
constructor(message: string, stderr: string) {
super(message, stderr)
this.name = 'ClaudeTimeoutError'
}
}
interface ParsedClaudeJson {
session_id: string
result: string
}
function tryParseClaudeJson(text: string): ParsedClaudeJson | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
}
catch {
return null
}
if (
parsed
&& typeof parsed === 'object'
&& 'result' in parsed
&& 'session_id' in parsed
) {
const obj = parsed as Record<string, unknown>
if (typeof obj.result === 'string' && typeof obj.session_id === 'string') {
return { session_id: obj.session_id, result: obj.result }
}
}
return null
}
function extractClaudePayload(stdout: string): ParsedClaudeJson | null {
const trimmed = stdout.trim()
if (!trimmed)
return null
const direct = tryParseClaudeJson(trimmed)
if (direct)
return direct
// Fallback: streaming JSON (one object per line). Walk from the end to
// find the last line that parses with the right shape.
const lines = trimmed.split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (!line)
continue
const candidate = tryParseClaudeJson(line)
if (candidate)
return candidate
}
return null
}
function truncateDiagnostic(text: string): string {
if (text.length <= EXIT_DIAGNOSTIC_MAX_CHARS)
return text
return `${text.slice(0, EXIT_DIAGNOSTIC_MAX_CHARS)}…`
}
function formatDiagnosticValue(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || null
}
if (typeof value === 'number' || typeof value === 'boolean')
return String(value)
if (value && typeof value === 'object') {
try {
return JSON.stringify(value)
}
catch {
return null
}
}
return null
}
function extractJsonDiagnostic(text: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
}
catch {
return null
}
if (!parsed || typeof parsed !== 'object')
return null
const obj = parsed as Record<string, unknown>
for (const key of ['error', 'message', 'result', 'summary']) {
const diagnostic = formatDiagnosticValue(obj[key])
if (diagnostic)
return truncateDiagnostic(diagnostic)
}
return null
}
function extractStdoutDiagnostic(stdout: string): string | null {
const trimmed = stdout.trim()
if (!trimmed)
return null
const direct = extractJsonDiagnostic(trimmed)
if (direct)
return direct
const lines = trimmed.split('\n')
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (!line)
continue
const diagnostic = extractJsonDiagnostic(line)
if (diagnostic)
return diagnostic
}
return truncateDiagnostic(trimmed)
}
function buildClaudeExitDiagnostic(stderr: string, stdout: string): string {
const stderrDiagnostic = stderr.trim()
if (stderrDiagnostic)
return truncateDiagnostic(stderrDiagnostic)
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)'
}
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
const content: Array<Record<string, unknown>> = [
{ type: 'text', text: prompt },
]
for (const img of images) {
content.push({
type: 'image',
source: {
type: 'base64',
media_type: img.mediaType,
data: img.base64,
},
})
}
const message = {
type: 'user',
message: { role: 'user', content },
parent_tool_use_id: null,
}
return `${JSON.stringify(message)}\n`
}
/**
* 构造后台 claude 子进程的环境变量:从父进程 env 剔除会导致 headless 鉴权
* 失败或嵌套会话静默退出的变量,让子进程回落到订阅 OAuth 凭据。
*
* - ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKENheadless `-p` 无条件优先用它们,
* 若属于受限/过期 org 会 403 Request not allowed。剔除后回落 ~/.claude 订阅登录。
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
*/
const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin'
function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv {
// 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源
// profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN
// 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。
const NESTED_GUARDS = new Set([
'CLAUDECODE',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_SESSION',
])
const out: NodeJS.ProcessEnv = {}
for (const [k, v] of Object.entries(process.env)) {
if (k.startsWith('ANTHROPIC_'))
continue
if (NESTED_GUARDS.has(k))
continue
out[k] = v
}
// claude 再 spawn 子工具时仍要能找到自己所在目录
if (bin) {
const dir = path.dirname(bin)
const prev = out.PATH ?? ''
out.PATH = prev ? `${dir}${path.delimiter}${prev}` : dir
}
return out
}
export async function spawnClaude(opts: {
prompt: string
cwd: string
timeoutMs?: number
images?: ClaudeImage[]
profilePath?: string
bare?: boolean
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
resumeSessionId?: string
}): Promise<ClaudeResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const hasImages = !!opts.images && opts.images.length > 0
if (!hasImages) {
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId)
}
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId)
}
function spawnClaudeText(
prompt: string,
cwd: string,
timeoutMs: number,
profilePath?: string,
bare?: boolean,
resumeSessionId?: string,
): Promise<ClaudeResult> {
const args = [
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
'-p',
prompt,
'--output-format',
'json',
]
const bin = resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const child = spawn(bin, args, {
cwd,
env: buildClaudeChildEnv(bin),
stdio: ['ignore', 'pipe', 'pipe'],
})
const timer = setTimeout(() => {
if (settled)
return
settled = true
child.kill('SIGKILL')
reject(new ClaudeTimeoutError(
`Claude 调用超时(${Math.round(timeoutMs / 1000)}s`,
stderr,
))
}, timeoutMs)
child.on('error', (err) => {
if (settled)
return
settled = true
clearTimeout(timer)
const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') {
reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr))
return
}
reject(new ClaudeError(
`Claude 调用失败: ${err.message}`,
stderr,
))
})
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length
if (stdoutBytes > MAX_BUFFER_BYTES) {
if (settled)
return
settled = true
clearTimeout(timer)
child.kill('SIGKILL')
reject(new ClaudeError(
`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`,
stderr,
))
return
}
stdout += chunk.toString('utf8')
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('close', (code) => {
if (settled)
return
settled = true
clearTimeout(timer)
if (code !== 0) {
reject(new ClaudeError(
`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`,
stderr,
))
return
}
const payload = extractClaudePayload(stdout)
if (!payload) {
reject(new ClaudeError(
'Claude 返回异常: 无法解析 JSON 输出',
stdout,
))
return
}
resolve({
sessionId: payload.session_id,
resultText: payload.result,
rawJson: stdout,
})
})
})
}
function spawnClaudeStreamed(
prompt: string,
cwd: string,
timeoutMs: number,
images: ClaudeImage[],
profilePath?: string,
bare?: boolean,
resumeSessionId?: string,
): Promise<ClaudeResult> {
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
// be paired with --output-format=stream-json (the friendlier `--output-format
// json` is rejected). The output is NDJSON; extractClaudePayload walks lines
// from the end to find the final result-shaped object.
const args = [
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
]
const ndjson = buildStreamJsonLine(prompt, images)
const bin = resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const child = spawn(bin, args, {
cwd,
env: buildClaudeChildEnv(bin),
stdio: ['pipe', 'pipe', 'pipe'],
})
const timer = setTimeout(() => {
if (settled)
return
settled = true
child.kill('SIGKILL')
reject(new ClaudeTimeoutError(
`Claude 调用超时(${Math.round(timeoutMs / 1000)}s`,
stderr,
))
}, timeoutMs)
child.on('error', (err) => {
if (settled)
return
settled = true
clearTimeout(timer)
const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') {
reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr))
return
}
reject(new ClaudeError(
`Claude 调用失败: ${err.message}`,
stderr,
))
})
child.stdout.on('data', (chunk: Buffer) => {
stdoutBytes += chunk.length
if (stdoutBytes > MAX_BUFFER_BYTES) {
if (settled)
return
settled = true
clearTimeout(timer)
child.kill('SIGKILL')
reject(new ClaudeError(
`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`,
stderr,
))
return
}
stdout += chunk.toString('utf8')
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('close', (code) => {
if (settled)
return
settled = true
clearTimeout(timer)
if (code !== 0) {
reject(new ClaudeError(
`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`,
stderr,
))
return
}
const payload = extractClaudePayload(stdout)
if (!payload) {
reject(new ClaudeError(
'Claude 返回异常: 无法解析 JSON 输出',
stdout,
))
return
}
resolve({
sessionId: payload.session_id,
resultText: payload.result,
rawJson: stdout,
})
})
// Send the single NDJSON line and close stdin so claude knows the user
// turn is complete. If the child has already exited (e.g. arg validation
// failed), stdin will EPIPE — swallow it; the 'close' handler will surface
// the real error from stderr + exit code.
child.stdin.on('error', () => { /* ignore EPIPE / ECONNRESET */ })
try {
child.stdin.write(ndjson)
child.stdin.end()
}
catch {
// child already gone; close handler will run with the error code.
}
})
}