This commit is contained in:
cruldra committed 2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 files changed
+54215

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
/**
* Watch the codex sessions root directory for the next new `rollout-*.jsonl`
* file to appear, returning its thread_id.
*
* Used by the auto-review flow: when we `sendText('codex exec review ...')`
* into a terminal tab, codex writes its session transcript to
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.jsonl`. We can't sniff
* the terminal's stdout, so instead we watch this filesystem root for new
* files and extract the thread_id from the filename / first line.
*
* File layout (probed 2026-05-20):
*
* - Directory: ~/.codex/sessions/YYYY/MM/DD/
* - Filename: rollout-2026-04-27T02-57-26-019dcb27-58a6-70a1-a5d1-bfc7f3ed9d0a.jsonl
* └────────────── thread_id (UUID v7) ──────────────┘
* - First line: {"timestamp":"...","type":"session_meta","payload":{"id":"019dcb27-...","cwd":"..."}}
*
* Strategy: prefer UUID extracted from the filename (no I/O); fall back to
* parsing the first JSON line if the regex doesn't match.
*/
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
/** UUID at the tail of `rollout-<ISO>-<uuid>.jsonl`. v7 UUIDs are still 8-4-4-4-12. */
const ROLLOUT_UUID_REGEX = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
/**
* Try to extract a thread_id (UUID) from a rollout filename.
* Returns null when the name doesn't match the expected pattern.
*/
function extractIdFromFilename(filename: string): string | null {
const m = filename.match(ROLLOUT_UUID_REGEX)
return m ? m[1] : null
}
/**
* Read the first line of `filePath` and try to extract `payload.id`
* (codex's session_meta thread id). Also accepts top-level `thread_id` /
* `session_id` / `id` for robustness against future codex format tweaks.
*/
async function extractIdFromFirstLine(filePath: string): Promise<string | null> {
let raw: string
try {
// Files are typically <100KB at session start; reading the whole thing
// and slicing to the first newline is simpler than a streaming reader
// and avoids race-with-partial-write issues.
raw = await fsp.readFile(filePath, 'utf8')
}
catch {
return null
}
const nl = raw.indexOf('\n')
const firstLine = nl === -1 ? raw : raw.slice(0, nl)
if (!firstLine.trim())
return null
let parsed: unknown
try {
parsed = JSON.parse(firstLine)
}
catch {
return null
}
if (!parsed || typeof parsed !== 'object')
return null
const obj = parsed as Record<string, unknown>
const payload = obj.payload
if (payload && typeof payload === 'object') {
const pid = (payload as Record<string, unknown>).id
if (typeof pid === 'string' && pid.length > 0)
return pid
}
for (const k of ['thread_id', 'session_id', 'id'] as const) {
const v = obj[k]
if (typeof v === 'string' && v.length > 0)
return v
}
return null
}
/**
* Watch `baseDir` recursively for the next new `rollout-*.jsonl` file and
* resolve with its thread_id. Resolves with null on timeout, watcher error,
* or when `fs.watch` isn't supported on this platform.
*
* Caller is responsible for ensuring `baseDir` exists; if it doesn't, this
* returns null immediately.
*/
export async function watchForNewCodexSession(opts: {
/** Base directory to watch recursively, typically `~/.codex/sessions`. */
baseDir: string
/** Max milliseconds to wait before giving up. */
timeoutMs: number
}): Promise<string | null> {
const { baseDir, timeoutMs } = opts
try {
const stat = await fsp.stat(baseDir)
if (!stat.isDirectory())
return null
}
catch {
// baseDir doesn't exist (codex never run on this machine?). The first
// codex run will create it, but `fs.watch` on a missing path throws —
// bail out so the caller doesn't hang.
return null
}
return new Promise<string | null>((resolve) => {
let settled = false
let watcher: fs.FSWatcher | null = null
let timer: NodeJS.Timeout | null = null
// Dedupe: fs.watch fires 'rename' twice on some platforms; skip files we
// already inspected this session.
const seen = new Set<string>()
const done = (id: string | null): void => {
if (settled)
return
settled = true
if (timer) {
clearTimeout(timer)
timer = null
}
if (watcher) {
try {
watcher.close()
}
catch {
// ignore close errors
}
watcher = null
}
resolve(id)
}
try {
// `recursive: true` is supported on Linux since Node 20. On older
// runtimes the call throws — fall through to done(null).
watcher = fs.watch(baseDir, { persistent: false, recursive: true }, (event, filename) => {
if (settled || !filename)
return
if (event !== 'rename')
return
const name = filename.toString()
if (!name.endsWith('.jsonl'))
return
const base = path.basename(name)
if (!base.startsWith('rollout-'))
return
if (seen.has(name))
return
seen.add(name)
const full = path.join(baseDir, name)
// `rename` fires for both create AND delete; confirm the file is
// really there before claiming a hit.
fsp.stat(full).then(async () => {
if (settled)
return
let id = extractIdFromFilename(base)
if (!id)
id = await extractIdFromFirstLine(full)
if (id)
done(id)
}).catch(() => {
// File no longer exists (delete event) — ignore.
})
})
watcher.on('error', (err) => {
// eslint-disable-next-line no-console
console.warn('[superpowers] codexSessionWatcher: watch error', err)
done(null)
})
}
catch (err) {
// eslint-disable-next-line no-console
console.warn('[superpowers] codexSessionWatcher: fs.watch threw', err)
done(null)
return
}
timer = setTimeout(() => {
done(null)
}, timeoutMs)
})
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Lists Claude settings profiles from a hardcoded directory. Each `.json`
* file there is treated as a profile whose `name` is its basename without
* the extension and whose `path` is the absolute file path, suitable for
* passing to `claude --settings <path>`.
*
* Failures (directory missing, unreadable, etc.) are swallowed — the
* caller treats an empty list as "no profile selector".
*/
import { readdir } from 'node:fs/promises'
import * as path from 'node:path'
const PROFILES_DIR = '/home/cruldra/Sources/cruldra-profile/claude-config/profiles'
export interface ClaudeProfile {
/** basename without .json */
name: string
/** absolute path */
path: string
}
/**
* List Claude settings profiles from the hardcoded directory. Returns an
* empty array if the directory is missing or unreadable; does not throw.
* Sorted alphabetically by name.
*/
export async function listClaudeProfiles(): Promise<ClaudeProfile[]> {
try {
const entries = await readdir(PROFILES_DIR)
const profiles: ClaudeProfile[] = []
for (const entry of entries) {
if (!entry.endsWith('.json'))
continue
const name = entry.slice(0, -'.json'.length)
profiles.push({ name, path: path.join(PROFILES_DIR, entry) })
}
profiles.sort((a, b) => a.name.localeCompare(b.name))
return profiles
}
catch {
return []
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Prompt templates for the two claude entry points (brainstorm and
* implement-plan). Defaults live in `src/settings/store.ts` (alongside the
* other tunables); these helpers just read the persisted setting and run
* placeholder substitution.
*
* Substitution is a plain global string replace of the placeholder text
* (`{userRequest}` / `{planFile}`) — no template engine. If the user removes
* the placeholder, substitution is a no-op and that's their bug.
*/
import type { ExtensionContext } from 'vscode'
import { getSettings } from '../settings/store'
export function getBrainstormPrompt(
ctx: ExtensionContext,
vars: { userRequest: string, nonce: string, imagePaths?: string[] },
): string {
const tpl = getSettings(ctx).brainstormPrompt
let out = tpl
.split('{userRequest}')
.join(vars.userRequest)
.split('{nonce}')
.join(vars.nonce)
if (vars.imagePaths && vars.imagePaths.length > 0) {
const lines = vars.imagePaths.map(p => `- ${p}`).join('\n')
out += `\n\n参考图片(请用 Read 工具查看):\n${lines}`
}
return out
}
export function getBrainstormContinuePrompt(ctx: ExtensionContext, vars: { issueNumber: number, issueRef?: string }): string {
const tpl = getSettings(ctx).brainstormContinuePrompt
// For youtrack cards the board number is synthetic; substitute the readable
// id (e.g. LXF-12) so the cc agent references the real issue (readable via
// `opencli yt issues show <id>`), not a non-existent gitea number.
return tpl.split('{issueNumber}').join(vars.issueRef ?? String(vars.issueNumber))
}
export function getImplementPlanPrompt(ctx: ExtensionContext, vars: { planFile: string, issueNumber: number, issueRef?: string }): string {
const tpl = getSettings(ctx).implementPlanPrompt
return tpl
.split('{planFile}')
.join(vars.planFile)
.split('{issueNumber}')
.join(vars.issueRef ?? String(vars.issueNumber))
}
export function getReviewPrompt(ctx: ExtensionContext, vars: { prNumber: string }): string {
const tpl = getSettings(ctx).reviewPrompt
return tpl.split('{prNumber}').join(vars.prNumber)
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Spawns `codex exec review --json` headlessly and waits for it to finish.
*
* Output handling: codex itself is responsible for posting the review back
* as a PR comment (instructed via the review prompt). This wrapper only
* scans codex's NDJSON stdout for the very first `thread.started` event so
* the caller can persist the `thread_id` (review session id) for manual
* resume; everything after that is ignored. Failures are logged but never
* thrown — the webhook coordinator treats review runs as fire-and-forget.
*/
import { spawn } from 'node:child_process'
import { logger } from '../logging/logger'
export interface RunReviewOpts {
workspaceRoot: string
/** Already-substituted prompt — placeholders must be resolved by the caller. */
prompt: string
/** Spawn timeout in milliseconds. Defaults to 5 minutes. */
timeoutMs?: number
/**
* Called once with the codex `thread_id` parsed from the first
* `thread.started` NDJSON event. Errors thrown inside this callback are
* caught and logged — they never propagate out of `runReview`.
*/
onThreadId?: (id: string) => void | Promise<void>
}
const DEFAULT_TIMEOUT_MS = 300_000
/**
* Run `codex exec review --json` and wait for it to exit. Errors are
* logged as warnings; this function never throws.
*/
export async function runReview(opts: RunReviewOpts): Promise<void> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const args: string[] = [
'exec',
'-c',
'model_reasoning_effort=xhigh',
'review',
'--dangerously-bypass-approvals-and-sandbox',
'--json',
opts.prompt,
]
await new Promise<void>((resolve) => {
let child: ReturnType<typeof spawn>
try {
child = spawn('codex', args, {
cwd: opts.workspaceRoot,
stdio: ['ignore', 'pipe', 'pipe'],
})
}
catch (err) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'codex exec review 启动失败',
details: err instanceof Error ? err.message : String(err),
})
resolve()
return
}
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
try {
child.kill('SIGTERM')
}
catch {
// ignore
}
}, timeoutMs)
// NDJSON parsing: accumulate stdout, split on newline, parse each line.
// Stop parsing after the first `thread.started` event — let the rest
// of stdout drain to /dev/null so codex can keep running.
let buf = ''
let threadIdHandled = false
let stderrTail = ''
let diagLogged = 0
const handleLine = (line: string): void => {
if (threadIdHandled || !line.trim())
return
// Diagnostic: log the first 3 lines so we can inspect codex's actual
// event shape if thread id detection misses.
if (diagLogged < 3) {
logger.add({
level: 'info',
source: 'webhook',
message: `codex stdout[${diagLogged}]`,
details: line.slice(0, 500),
})
diagLogged += 1
}
let parsed: unknown
try {
parsed = JSON.parse(line)
}
catch {
return
}
if (!parsed || typeof parsed !== 'object')
return
const obj = parsed as { type?: unknown, thread_id?: unknown, session_id?: unknown, id?: unknown }
// Try multiple field names: thread_id (current), session_id (older
// codex), id (fallback when the event is a "session created" shape).
// Accept any event so long as it carries one of these as a string.
const candidate
= (typeof obj.thread_id === 'string' && obj.thread_id.length > 0 && obj.thread_id)
|| (typeof obj.session_id === 'string' && obj.session_id.length > 0 && obj.session_id)
|| (typeof obj.id === 'string' && obj.id.length > 0 && /^[0-9a-f-]{16,}$/i.test(obj.id) && obj.id)
|| ''
if (candidate) {
threadIdHandled = true
const id = candidate
if (opts.onThreadId) {
try {
const ret = opts.onThreadId(id)
if (ret && typeof (ret as Promise<unknown>).then === 'function') {
;(ret as Promise<unknown>).catch((err) => {
logger.add({
level: 'warn',
source: 'webhook',
message: 'onThreadId 回调异常',
details: err instanceof Error ? err.message : String(err),
})
})
}
}
catch (err) {
logger.add({
level: 'warn',
source: 'webhook',
message: 'onThreadId 回调抛错',
details: err instanceof Error ? err.message : String(err),
})
}
}
}
}
child.stdout?.setEncoding('utf-8')
child.stdout?.on('data', (chunk: string) => {
if (threadIdHandled)
return
buf += chunk
let idx = buf.indexOf('\n')
while (idx >= 0) {
const line = buf.slice(0, idx)
buf = buf.slice(idx + 1)
handleLine(line)
if (threadIdHandled) {
buf = ''
break
}
idx = buf.indexOf('\n')
}
})
child.stderr?.setEncoding('utf-8')
child.stderr?.on('data', (chunk: string) => {
// Keep only the tail so we can include it in the failure message.
stderrTail = (stderrTail + chunk).slice(-2000)
})
child.on('error', (err) => {
clearTimeout(timer)
logger.add({
level: 'warn',
source: 'webhook',
message: 'codex exec review 进程错误',
details: err instanceof Error ? err.message : String(err),
})
resolve()
})
child.on('close', (code, signal) => {
clearTimeout(timer)
if (timedOut) {
logger.add({
level: 'warn',
source: 'webhook',
message: `codex exec review 超时 (${timeoutMs}ms)`,
details: stderrTail.slice(-500),
})
}
else if ((code !== null && code !== 0) || (signal && !timedOut)) {
logger.add({
level: 'warn',
source: 'webhook',
message: `codex exec review 退出码异常 code=${code} signal=${signal ?? '<none>'}`,
details: stderrTail.slice(-500),
})
}
resolve()
})
})
}
+208
View File
@@ -0,0 +1,208 @@
/**
* Watch a claude projects directory for the next new .jsonl file to appear.
*
* Used by the "实施" flow: when we spawn `claude` in a worktree, the CLI
* writes its session transcript to `~/.claude/projects/<encoded-cwd>/<sid>.jsonl`.
* We pre-snapshot the directory, then watch for any new `.jsonl` whose name
* isn't in the snapshot — that file's basename (minus `.jsonl`) is the
* implementation session id.
*/
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
/**
* Watch a claude projects directory for the next new .jsonl file to appear,
* returning its basename (without the .jsonl suffix) — that's the session id.
*
* Pre-snapshots existing files at start time so a later-created file is what
* resolves the promise. Times out to null after `timeoutMs`.
*
* Caller is responsible for ensuring the directory exists before calling
* (use `fs.mkdir(dir, { recursive: true })`).
*/
export async function watchForNewSession(opts: {
projectsDir: string
timeoutMs?: number
}): Promise<string | null> {
const { projectsDir, timeoutMs = 120_000 } = opts
let snapshot: Set<string>
try {
const existing = await fsp.readdir(projectsDir)
snapshot = new Set(existing.filter(n => n.endsWith('.jsonl')))
}
catch (err) {
// eslint-disable-next-line no-console
console.warn('[superpowers] sessionWatcher: readdir failed', err)
return null
}
return new Promise<string | null>((resolve) => {
let settled = false
let watcher: fs.FSWatcher | null = null
let timer: NodeJS.Timeout | null = null
const done = (sid: string | null): void => {
if (settled)
return
settled = true
if (timer) {
clearTimeout(timer)
timer = null
}
if (watcher) {
try {
watcher.close()
}
catch {
// ignore close errors
}
watcher = null
}
resolve(sid)
}
try {
watcher = fs.watch(projectsDir, { persistent: false }, (event, filename) => {
if (settled)
return
if (event !== 'rename' || !filename)
return
const name = filename.toString()
if (!name.endsWith('.jsonl'))
return
if (snapshot.has(name))
return
// `rename` fires for both create and delete; confirm the file is
// really there before resolving.
const full = path.join(projectsDir, name)
fsp.stat(full).then(() => {
if (settled)
return
const sid = name.slice(0, -'.jsonl'.length)
done(sid)
}).catch(() => {
// File no longer exists (delete event) — ignore.
})
})
watcher.on('error', (err) => {
// eslint-disable-next-line no-console
console.warn('[superpowers] sessionWatcher: watch error', err)
done(null)
})
}
catch (err) {
// eslint-disable-next-line no-console
console.warn('[superpowers] sessionWatcher: fs.watch threw', err)
done(null)
return
}
timer = setTimeout(() => {
done(null)
}, timeoutMs)
})
}
/**
* 轮询版会话捕获:snapshot 现有 .jsonl,之后每 intervalMs 轮询一次目录,
* 返回首个新出现的 .jsonl 的 session id(去掉 .jsonl 后缀)。超时返回 null。
*
* 比 fs.watch 在繁忙/共享 projects 目录里更可靠:fs.watch 的 rename 事件在
* 高并发写入的目录里会漏掉,导致永远等不到新会话。轮询通过周期性 readdir
* diff 来避免这个问题。
*
* Caller is responsible for ensuring the directory exists before calling
* (use `fs.mkdir(dir, { recursive: true })`).
*/
export async function pollForNewSession(opts: {
projectsDir: string
timeoutMs?: number
intervalMs?: number
}): Promise<string | null> {
const { projectsDir, timeoutMs = 120_000, intervalMs = 1000 } = opts
let snapshot: Set<string>
try {
const existing = await fsp.readdir(projectsDir)
snapshot = new Set(existing.filter(n => n.endsWith('.jsonl')))
}
catch (err) {
// eslint-disable-next-line no-console
console.warn('[superpowers] sessionWatcher: poll readdir failed', err)
return null
}
return new Promise<string | null>((resolve) => {
let settled = false
let timer: NodeJS.Timeout | null = null
const deadline = Date.now() + timeoutMs
const done = (sid: string | null): void => {
if (settled)
return
settled = true
if (timer) {
clearInterval(timer)
timer = null
}
resolve(sid)
}
const tick = async (): Promise<void> => {
if (settled)
return
if (Date.now() >= deadline) {
done(null)
return
}
let current: string[]
try {
current = await fsp.readdir(projectsDir)
}
catch {
// 目录可能临时 readdir 失败(被并发改写等);忽略本轮,继续轮询。
return
}
const fresh = current.filter(n => n.endsWith('.jsonl') && !snapshot.has(n))
if (fresh.length === 0)
return
// 多个新文件时取 mtime 最新的那个。
let newest: string | null = null
let newestMtime = -1
for (const name of fresh) {
try {
const st = await fsp.stat(path.join(projectsDir, name))
if (st.mtimeMs > newestMtime) {
newestMtime = st.mtimeMs
newest = name
}
}
catch {
// 文件可能已被删除;跳过。
}
}
if (settled)
return
if (newest)
done(newest.slice(0, -'.jsonl'.length))
}
timer = setInterval(() => {
void tick()
}, intervalMs)
})
}
/** Convert an absolute path to claude's projects-dir encoding ('/' -> '-'). */
export function encodeCwdForProjectsDir(absPath: string): string {
return absPath.replace(/[/.]/g, '-')
}
/** Absolute path of the claude projects subdir for a given cwd. */
export function projectsDirFor(absCwd: string): string {
return path.join(os.homedir(), '.claude', 'projects', encodeCwdForProjectsDir(absCwd))
}
+485
View File
@@ -0,0 +1,485 @@
/**
* 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'
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_TOKEN:headless `-p` 无条件优先用它们,
* 若属于受限/过期 org 会 403 Request not allowed。剔除后回落 ~/.claude 订阅登录。
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION:
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
*/
function buildClaudeChildEnv(): 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
}
return out
}
export async function spawnClaude(opts: {
prompt: string
cwd: string
timeoutMs?: number
images?: ClaudeImage[]
profilePath?: string
bare?: boolean
}): 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)
}
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare)
}
function spawnClaudeText(
prompt: string,
cwd: string,
timeoutMs: number,
profilePath?: string,
bare?: boolean,
): Promise<ClaudeResult> {
const args = [
...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions',
...(profilePath ? ['--settings', profilePath] : []),
'-p',
prompt,
'--output-format',
'json',
]
return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const child = spawn('claude', args, {
cwd,
env: buildClaudeChildEnv(),
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 CLI,请确认已安装并在 PATH 中',
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,
): 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] : []),
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
]
const ndjson = buildStreamJsonLine(prompt, images)
return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = ''
let stderr = ''
let stdoutBytes = 0
let settled = false
const child = spawn('claude', args, {
cwd,
env: buildClaudeChildEnv(),
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 CLI,请确认已安装并在 PATH 中',
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.
}
})
}