'use strict'; // Claude 引擎:spawn `claude -p --output-format stream-json --verbose`,逐行解析事件。 // prompt 走 stdin(-p 沒有位置參數時讀 stdin),任意文字都安全。 // 沙盒對應:read-only → 禁用寫檔/執行工具;workspace-write → 自動接受檔案編輯(不執行指令); // danger-full-access → 跳過所有權限確認。 const { spawn, execSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin'); let cachedCommand = null; // 找 claude 執行檔實體:pm2 daemon 的 PATH 不一定帶到 ~/.local/bin(原生安裝位置),直接補找 function resolveClaudeCommand(config) { const custom = config && config.claudePath; if (custom) return { cmd: custom, shell: /\.cmd$/i.test(custom) }; if (cachedCommand) return cachedCommand; const names = process.platform === 'win32' ? ['claude.exe', 'claude.cmd'] : ['claude']; const dirs = [...(process.env.PATH || '').split(path.delimiter), LOCAL_BIN]; for (const dir of dirs) { if (!dir) continue; for (const n of names) { const p = path.join(dir, n); if (fs.existsSync(p)) { cachedCommand = { cmd: p, shell: /\.cmd$/i.test(p) }; return cachedCommand; } } } cachedCommand = { cmd: 'claude', shell: process.platform === 'win32' }; return cachedCommand; } // 只有退回 shell(.cmd shim)時才需要引號;參數裡不會有引號,包起來即可 function quoteForShell(s) { return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s; } function killTree(proc) { if (process.platform === 'win32') { try { execSync(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true, stdio: 'ignore' }); } catch { /* ignore */ } } else { try { proc.kill('SIGKILL'); } catch { /* ignore */ } } } const TOOL_LABELS = { Bash: '⚙️ 執行指令', Read: '📖 讀取檔案', Write: '✏️ 寫入檔案', Edit: '✏️ 編輯檔案', MultiEdit: '✏️ 編輯檔案', NotebookEdit: '✏️ 編輯筆記本', Glob: '🔍 搜尋檔案', Grep: '🔍 搜尋內容', WebFetch: '🌐 擷取網頁', WebSearch: '🔎 搜尋網路', Agent: '🤖 派出子代理', Task: '🤖 派出子代理', }; function permissionArgs(sandbox) { switch (sandbox) { case 'read-only': return ['--permission-mode', 'default', '--disallowedTools', 'Write,Edit,MultiEdit,NotebookEdit,Bash']; case 'danger-full-access': return ['--dangerously-skip-permissions']; default: // workspace-write return ['--permission-mode', 'acceptEdits']; } } /** * 執行一輪 Claude 對話。介面同 engines/codex.js 的 run。 * @returns {Promise<{text: string, threadId: string|null, usage: object|null, model: string|null, denials: object[]}>} */ function run({ config, prompt, threadId, images = [], onProgress }) { return new Promise((resolve, reject) => { const { cmd, shell } = resolveClaudeCommand(config); const args = ['-p', '--output-format', 'stream-json', '--verbose']; if (config.model) args.push('--model', config.model); if (config.reasoningEffort) args.push('--effort', config.reasoningEffort); args.push(...permissionArgs(config.sandbox)); // 附圖:claude -p 沒有圖片參數,改讓 Read 工具讀(要先把暫存目錄加進允許範圍) if (images.length > 0) args.push('--add-dir', path.dirname(images[0])); if (threadId) args.push('--resume', threadId); let fullPrompt = prompt; if (images.length > 0) { fullPrompt += '\n\n【附圖】請先用 Read 工具讀取下列圖片檔再處理:\n' + images.join('\n'); } const env = { ...process.env }; // 讓子程序也找得到 claude 與它可能呼叫的工具 env.PATH = `${env.PATH || ''}${path.delimiter}${LOCAL_BIN}`; const proc = shell ? spawn(quoteForShell(cmd) + ' ' + args.map(quoteForShell).join(' '), { cwd: config.workDir, env, shell: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) : spawn(cmd, args, { cwd: config.workDir, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); proc.stdin.write(fullPrompt); proc.stdin.end(); let buffer = ''; let stderrTail = ''; let sessionId = threadId || null; let model = null; let resultText = null; let resultError = null; let usage = null; let denials = []; let lastText = ''; const timer = setTimeout(() => { killTree(proc); reject(new Error(`Claude 逾時(${config.timeoutMinutes} 分鐘)`)); }, config.timeoutMinutes * 60 * 1000); const handleEvent = (obj) => { if (obj.type === 'system') { if (obj.session_id) sessionId = obj.session_id; if (obj.subtype === 'init' && obj.model) model = obj.model; return; } if (obj.type === 'assistant') { if (obj.message?.model) model = obj.message.model; if (!onProgress) return; let toolLabel = null; for (const block of obj.message?.content || []) { if (block.type === 'text' && block.text?.trim()) { const t = block.text.trim(); lastText = t.length > 600 ? t.slice(0, 600) + '...' : t; } else if (block.type === 'tool_use') { let detail = ''; if (block.name === 'Bash' && block.input?.command) { const c = String(block.input.command); detail = c.length > 300 ? c.slice(0, 300) + '...' : c; } else if (block.input?.file_path) { detail = String(block.input.file_path); } toolLabel = (TOOL_LABELS[block.name] || `🔧 ${block.name}`) + '...' + (detail ? '\n' + detail : ''); } } if (toolLabel) onProgress(toolLabel); else if (lastText) onProgress('💬 ' + lastText); return; } if (obj.type === 'result') { if (obj.session_id) sessionId = obj.session_id; denials = Array.isArray(obj.permission_denials) ? obj.permission_denials : []; const u = obj.usage || null; if (u) { // iterations 有的話取最後一輪:那才是目前會話的實際上下文大小 const iters = u.iterations; const ref = (Array.isArray(iters) && iters.length > 0) ? iters[iters.length - 1] : u; const mu = obj.modelUsage && (obj.modelUsage[model] || Object.values(obj.modelUsage)[0]); usage = { input_tokens: (ref.input_tokens || 0) + (ref.cache_creation_input_tokens || 0) + (ref.cache_read_input_tokens || 0), cached_input_tokens: ref.cache_read_input_tokens || 0, output_tokens: ref.output_tokens || 0, context_window: mu?.contextWindow || 200000, }; if (mu?.canonicalModel) model = model || mu.canonicalModel; } if (obj.is_error) { const errs = Array.isArray(obj.errors) ? obj.errors.join('; ') : ''; resultError = [obj.result, errs].filter(Boolean).join('\n') || obj.subtype || 'Claude 回報錯誤'; } else { resultText = obj.result ?? ''; } } }; proc.stdout.on('data', (chunk) => { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; try { handleEvent(JSON.parse(line)); } catch { /* 非 JSON 行直接忽略 */ } } }); proc.stderr.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-2000); if (process.env.DEBUG) console.error('[claude stderr]', chunk.toString()); }); proc.on('close', (code) => { clearTimeout(timer); if (resultText !== null) { return resolve({ text: resultText, threadId: sessionId, usage, model, denials }); } const detail = resultError || stderrTail.split('\n').filter((l) => l.trim()).slice(-5).join('\n'); const e = new Error(resultError ? `Claude 執行失敗:${resultError}` : `claude 結束(exit code ${code})${detail ? '\n' + detail : ''}`); e.threadId = sessionId; e.transient = /ECONNRESET|socket hang up|timed out|stream|overloaded|529|rate limit|connection/i.test(detail || ''); reject(e); }); proc.on('error', (err) => { clearTimeout(timer); reject(new Error(`無法啟動 claude:${err.message}(請確認已安裝 Claude Code 並在 PATH 中)`)); }); }); } module.exports = { run, resolveClaudeCommand };