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

655 lines
19 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. 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.
* When the caller passes `onProgress`, this path switches to
* `--output-format stream-json --verbose` so tool calls can be reported
* live instead of only when the process exits.
*
* - **With images**: switch to `claude -p --input-format stream-json
* --output-format stream-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 type { Buffer } from 'node:buffer'
import { spawn } from 'node:child_process'
import * as path from 'node:path'
import process from 'node:process'
import { resolveClaudeBin } from './claudeBin'
import { summarizeClaudeStreamLine } from './claudeProgress'
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'
}
}
/** 调用方叫停(AbortSignal)时进程已被结束。 */
export class ClaudeCancelledError extends ClaudeError {
constructor(stderr: string) {
super('Claude 调用已停止', stderr)
this.name = 'ClaudeCancelledError'
}
}
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)'
}
export type ClaudeProgressListener = (label: string) => void
/**
* 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上;
* 进程结束时 flush 交出最后那段没带换行的。
*/
function createLineFeeder(onLine: (line: string) => void): { feed: (chunk: string) => void, flush: () => void } {
let pending = ''
return {
feed: (chunk) => {
pending += chunk
const lines = pending.split('\n')
pending = lines.pop() ?? ''
lines.forEach(onLine)
},
flush: () => {
if (pending !== '')
onLine(pending)
pending = ''
},
}
}
function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void {
if (!onProgress)
return () => {}
return createLineFeeder((line) => {
for (const label of summarizeClaudeStreamLine(line))
onProgress(label)
}).feed
}
/** `--input-format stream-json` 的一条用户消息(一行 NDJSON):先是文字,后面跟着各张图片。 */
export function buildStreamJsonLine(prompt: string, images: readonly 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_TOKEN:headless `-p` 无条件优先用它们,
* 若属于受限/过期 org 会 403 Request not allowed。剔除后回落 ~/.claude 订阅登录。
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION:
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
*/
export 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
/** 实时进度:每识别出一个工具调用 / 一句回复就回调一次可读标签。 */
onProgress?: ClaudeProgressListener
}): 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, opts.onProgress)
}
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId, opts.onProgress)
}
function spawnClaudeText(
prompt: string,
cwd: string,
timeoutMs: number,
profilePath?: string,
bare?: boolean,
resumeSessionId?: string,
onProgress?: ClaudeProgressListener,
): Promise<ClaudeResult> {
// `--output-format json` 要到进程结束才有输出;要报进度就得换成逐行吐的 stream-json
const args = [
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
'-p',
prompt,
'--output-format',
...(onProgress ? ['stream-json', '--verbose'] : ['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 feedProgress = createProgressFeeder(onProgress)
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
}
const text = chunk.toString('utf8')
stdout += text
feedProgress(text)
})
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,
onProgress?: ClaudeProgressListener,
): 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 feedProgress = createProgressFeeder(onProgress)
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
}
const text = chunk.toString('utf8')
stdout += text
feedProgress(text)
})
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.
}
})
}
/**
* 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 onLine。
* 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject ClaudeError,超时 ClaudeTimeoutError,
* signal 叫停时结束进程并 reject ClaudeCancelledError。
*/
export function runClaudeLines(opts: {
args: string[]
cwd: string
/** 写进 stdin 后关上(`--input-format stream-json` 的消息,见 buildStreamJsonLine);不给时 stdin 是空的 */
input?: string
onLine: (line: string) => void
timeoutMs?: number
signal?: AbortSignal
/** 默认按 resolveClaudeBin 找 */
bin?: string
}): Promise<void> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const bin = opts.bin ?? resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
if (opts.signal?.aborted)
return Promise.reject(new ClaudeCancelledError(''))
return new Promise<void>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const lines = createLineFeeder(opts.onLine)
const child = spawn(bin, opts.args, {
cwd: opts.cwd,
env: buildClaudeChildEnv(bin),
stdio: ['pipe', 'pipe', 'pipe'],
})
// 进程先退了(参数不对)时写不进去:真正的原因由退出码和 stderr 报
child.stdin.on('error', () => {})
child.stdin.end(opts.input ?? '')
const timer = setTimeout(() => {
child.kill('SIGKILL')
settle(new ClaudeTimeoutError(`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr))
}, timeoutMs)
function onAbort(): void {
child.kill('SIGTERM')
settle(new ClaudeCancelledError(stderr))
}
function settle(error?: ClaudeError): void {
if (settled)
return
settled = true
clearTimeout(timer)
opts.signal?.removeEventListener('abort', onAbort)
if (error)
reject(error)
else
resolve()
}
opts.signal?.addEventListener('abort', onAbort, { once: true })
child.on('error', (err) => {
const errCode = (err as NodeJS.ErrnoException).code
settle(new ClaudeError(errCode === 'ENOENT' ? CLAUDE_MISSING_MSG : `Claude 调用失败: ${err.message}`, stderr))
})
child.stdout.on('data', (chunk: Buffer) => {
if (settled)
return
stdoutBytes += chunk.length
if (stdoutBytes > MAX_BUFFER_BYTES) {
child.kill('SIGKILL')
settle(new ClaudeError(`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr))
return
}
const text = chunk.toString('utf8')
stdout += text
lines.feed(text)
})
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('close', (code) => {
if (settled)
return
lines.flush()
settle(code === 0
? undefined
: new ClaudeError(`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr))
})
})
}