213 lines
8.4 KiB
JavaScript
213 lines
8.4 KiB
JavaScript
'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 };
|