'use strict'; // Claude Code 헤드리스(-p) 실행 래퍼. // 사이트 디렉터리를 작업 폴더로 하여 Claude Opus 5.5 로 페이지를 생성/수정하고, // stream-json 이벤트를 파싱해 진행 로그와 최종 토큰 사용량을 돌려준다. const { spawn } = require('child_process'); const readline = require('readline'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const settings = require('./settings'); const os = require('os'); const fs = require('fs'); const { execFileSync } = require('child_process'); // claude 실행 파일 자동 탐색: CLAUDE_BIN → PATH → 잘 알려진 설치 위치 순. 실행 실패(ENOENT) 시 다시 탐색한다. const HOME = os.homedir(); const KNOWN_DIRS = [path.join(HOME, '.local', 'bin'), path.join(HOME, '.npm-global', 'bin'), '/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', path.join(HOME, '.claude', 'local'), path.join(HOME, 'node_modules', '.bin')]; function isExec(p) { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch (_) { return false; } } let resolvedBin = null; let resolvedAt = 0; function resolveClaudeBin(force) { if (resolvedBin && !force && Date.now() - resolvedAt < 60000 && isExec(resolvedBin)) return resolvedBin; const candidates = []; if (process.env.CLAUDE_BIN) candidates.push(process.env.CLAUDE_BIN); for (const d of (process.env.PATH || '').split(path.delimiter)) if (d) candidates.push(path.join(d, 'claude')); for (const d of KNOWN_DIRS) candidates.push(path.join(d, 'claude')); const found = candidates.find(isExec); resolvedBin = found || null; resolvedAt = Date.now(); return resolvedBin; } function claudeInfo() { const bin = resolveClaudeBin(true); if (!bin) return { bin: null, version: null, searched: [...new Set([process.env.CLAUDE_BIN, ...KNOWN_DIRS].filter(Boolean))] }; let version = null; try { version = execFileSync(bin, ['--version'], { timeout: 15000, env: cleanEnv(os.tmpdir()) }).toString().trim().split('\n')[0]; } catch (e) { version = '버전 확인 실패: ' + (e.message || '').split('\n')[0]; } return { bin, realpath: (() => { try { return fs.realpathSync(bin); } catch (_) { return bin; } })(), version, searched: [...new Set([process.env.CLAUDE_BIN, ...KNOWN_DIRS].filter(Boolean))] }; } const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-5-5'; // 기본값. 실제 실행 모델은 settings.getModel() const MAX_TURNS = parseInt(process.env.CLAUDE_MAX_TURNS || '80', 10); // v1.6.31 (요구 3): 훅 설정은 서버마다 다른 설치 경로(/home/master/webmaker, /srv/webmaker/current …)에 맞춰 기동 때 만든다. // 1.5.x 의 hooks/settings.json 은 개발 PC 의 절대 경로가 박혀 있어 다른 서버에서는 훅이 실행되지 못했다(명령 없음 → Claude 는 차단 없이 진행) → 가드가 사실상 꺼져 있었다. const HOOK_SETTINGS = (() => { const dir = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'run'); try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} const f = path.join(dir, 'claude-hooks.json'); try { fs.writeFileSync(f, JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|Bash|WebFetch|WebSearch', hooks: [{ type: 'command', command: path.join(ROOT, 'hooks', 'guard.sh') }] }] } }, null, 1)); } catch (e) { console.error('[claude] 훅 설정 파일 작성 실패: ' + e.message); } return f; })(); /** 작업 하나의 최대 실행 시간(분) — 설정 ai.maxJobMinutes(기본 45). 넘으면 중단해 다른 사용자·서버에 영향을 주지 않는다 (요구 3·12) */ const MAX_JOB_MIN = () => { try { const v = parseInt(require('./config').get('ai.maxJobMinutes') || '45', 10); return Number.isFinite(v) && v > 0 ? v : 45; } catch (_) { return 45; } }; const IDLE_MIN = 15; // 출력이 이만큼 없으면 멈춘 것으로 보고 중단 // 파일 조작 도구만 사용 가능하게 제한(--tools). Bash/Web/Task 등은 모델에 아예 노출되지 않는다. // 쓰기 범위는 hooks/guard.sh(PreToolUse) 가 사이트 폴더로 제한한다. const ALLOWED_TOOLS = 'Write,Edit,MultiEdit,Read,Glob,Grep'; // ============================================================================ // v1.4.5 이번 실행 비용 계산 (세션 누적값 문제) // // Claude CLI 결과의 total_cost_usd 와 modelUsage 는 "그 세션의 누적값" 이다. --resume 으로 같은 세션을 이어 가면 // 앞선 실행 비용이 계속 더해져 나온다(2026-10-10 실측: 새 세션 0.046121 → 이어서 0.0496223 → 0.0522053 USD). // 1.4.4 까지는 이 값을 그대로 "이번 작업 비용" 으로 청구해 같은 비용이 작업마다 다시 청구되었다. // 반면 결과의 usage(입력·출력·캐시 토큰)는 이번 실행분만이다. // // 그래서 세션마다 마지막 누적값을 장부(data/claude-sessions.json)에 남기고, 이번 비용 = 지금 누적 − 지난 누적 으로 구한다. // 장부를 믿을 수 없을 때는 이번 실행 usage 를 가격표로 계산한다(가격표 계산은 CLI 비용과 일치함을 실측으로 확인): // - 1.4.5 이전에 시작된 세션을 처음 이어 갈 때(장부에 없음) // - 지난 실행이 중단(취소·캐시 소진)되어 결과 누적값을 받지 못했을 때 — 그 실행의 완성된 메시지는 세션에 남아 // 다음 누적값에 들어가므로, 차이로 계산하면 이미 추정 청구한 중단 실행분이 한 번 더 청구된다 // - 누적값이 줄어든 경우(CLI 가 세션 비용을 새로 시작) // ============================================================================ const SESSION_LEDGER = path.join(process.env.DATA_ROOT || path.join(ROOT, 'data'), 'claude-sessions.json'); const LEDGER_DAYS = 120; // 이 기간 동안 쓰지 않은 세션 기록은 지운다 (파일이 끝없이 커지지 않게) function loadLedger() { try { return JSON.parse(fs.readFileSync(SESSION_LEDGER, 'utf8')) || {}; } catch (_) { return {}; } } function saveLedger(d) { const cut = Date.now() - LEDGER_DAYS * 86400000; for (const k of Object.keys(d)) if (!(Date.parse(d[k].at) > cut)) delete d[k]; try { fs.mkdirSync(path.dirname(SESSION_LEDGER), { recursive: true }); const tmp = SESSION_LEDGER + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(d), { mode: 0o600 }); fs.renameSync(tmp, SESSION_LEDGER); } catch (_) {} } /** 세션 장부에 "지난 실행이 중단되어 누적값을 모름" 표시 — 다음 실행은 가격표 계산 (위 설명) */ function markSessionDirty(sessionId) { if (!sessionId) return; const d = loadLedger(); d[sessionId] = { ...(d[sessionId] || {}), clean: false, at: new Date().toISOString() }; saveLedger(d); } /** modelUsage 두 개의 차이 (모델별 토큰·비용). 줄어든 값은 0 으로 */ function diffModelUsage(cur, prev) { const out = {}; for (const [m, v] of Object.entries(cur || {})) { const p = (prev || {})[m] || {}; const o = {}; for (const k of Object.keys(v)) o[k] = typeof v[k] === 'number' && /Tokens$|^costUSD$|Requests$/.test(k) ? Math.max(0, v[k] - (p[k] || 0)) : v[k]; if ((o.costUSD || 0) > 0 || (o.inputTokens || 0) + (o.outputTokens || 0) > 0) out[m] = o; } return out; } /** 이번 실행 usage 만으로 만든 modelUsage (가격표 계산 때) */ function runModelUsage(model, u, costUsd) { return { [model]: { inputTokens: u.input, outputTokens: u.output, cacheReadInputTokens: u.cacheRead, cacheCreationInputTokens: u.cacheCreate, thinkingTokens: u.thinking, costUSD: costUsd, priced: true } }; } /** * 이번 실행의 비용·모델별 사용량을 정한다. * @param {object} r CLI result 이벤트 * @param {object} usage 정규화한 이번 실행 usage * @param {object} o runClaudeOnce 옵션 (resume, sessionId, noPersist) * @returns {{costUsd, modelUsage, costSource: 'single'|'delta'|'priced', cumulativeUsd, pricedUsd}} */ function runCost(r, usage, model, o) { const cum = Number(r.total_cost_usd) || 0; const cumMU = r.modelUsage || {}; const pricedUsd = settings.estimateUsd(usage, model); // 이번 실행 토큰 × 가격표 (확인·대체용) if (o.noPersist) return { costUsd: cum, modelUsage: cumMU, costSource: 'single', cumulativeUsd: cum, pricedUsd }; // 세션을 남기지 않는 한 번짜리 실행: 누적 = 이번 const sid = o.sessionId || r.session_id; const d = loadLedger(); const prev = o.resume ? d[sid] : null; let out; if (!o.resume) out = { costUsd: cum, modelUsage: cumMU, costSource: 'single' }; // 새 세션의 첫 실행 else if (prev && prev.clean !== false && Number.isFinite(prev.costUsd) && cum + 1e-9 >= prev.costUsd) out = { costUsd: Math.max(0, cum - prev.costUsd), modelUsage: diffModelUsage(cumMU, prev.modelUsage), costSource: 'delta' }; else out = { costUsd: pricedUsd, modelUsage: runModelUsage(model, usage, pricedUsd), costSource: 'priced' }; if (sid) { d[sid] = { costUsd: cum, modelUsage: cumMU, clean: true, at: new Date().toISOString() }; saveLedger(d); } return { ...out, cumulativeUsd: cum, pricedUsd }; } /** * 자식(claude) 환경 변수 — v1.6.31 (요구 3): 서비스 프로세스의 환경을 통째로 넘기지 않고 허용 목록만 넘긴다. * 1.5.x 까지는 DATA_ROOT·SITES_ROOT·PORT 등 서비스 환경과 상위 세션 변수가 그대로 전달됐다(검토 S1). AI 가 셸로 env 를 보면 서버 구조가 드러났다. * HOME 은 claude 의 로그인 상태(~/.claude)를 위해 남긴다(서버 로그인 방식). 인증은 authEnv(API 키·OAuth 토큰)로만 넣는다. */ const ENV_ALLOW = ['PATH', 'HOME', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR', 'TZ', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'https_proxy', 'http_proxy', 'no_proxy', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME']; function cleanEnv(siteDir, authEnv) { const env = {}; for (const k of ENV_ALLOW) if (process.env[k] !== undefined) env[k] = process.env[k]; env.SITE_DIR = siteDir; // hooks/guard.js 가 참조 env.CI = '1'; // v1.6.31 (검토 S8): TLS 검증 완화는 기본 꺼짐. 사설 프록시 환경에서만 설정 ai.allowInsecureTls 로 켠다(자식 프로세스 전체의 TLS 검증이 꺼지므로 경고 대상) if (process.env.CLAUDE_ALLOW_INSECURE_TLS === '1') env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; env.HOOK_SCRIPT = path.join(ROOT, 'hooks', 'guard.sh'); if (!env.HOME) env.HOME = require('os').homedir(); Object.assign(env, authEnv || settings.authEnv()); // 실행별 지정(사용자 키) 또는 관리자 설정 const bin = resolvedBin; if (bin) { const dir = path.dirname(bin); const parts = (env.PATH || '').split(path.delimiter); if (!parts.includes(dir)) env.PATH = [dir, ...parts].filter(Boolean).join(path.delimiter); } return env; } function friendlyProgress(c) { const input = c.input || {}; const fp = input.file_path ? path.basename(input.file_path) : ''; switch (c.name) { case 'Write': return `파일 작성 중: ${fp}`; case 'Edit': case 'MultiEdit': return `파일 수정 중: ${fp}`; case 'Read': return `내용 확인 중: ${fp}`; case 'Glob': case 'Grep': return '파일 구조 확인 중'; case 'WebFetch': return '참고 웹 페이지 확인 중'; default: return '작업 진행 중'; } } function summarizeToolUse(c) { const input = c.input || {}; const fp = input.file_path ? path.basename(input.file_path) : ''; switch (c.name) { case 'Write': return `파일 작성: ${fp} (${(input.content || '').length.toLocaleString()}자)`; case 'Edit': case 'MultiEdit': return `파일 수정: ${fp}`; case 'Read': return `파일 읽기: ${fp}`; case 'Glob': return `파일 검색: ${input.pattern || ''}`; case 'Grep': return `내용 검색: ${input.pattern || ''}`; case 'WebFetch': return `웹 페이지 확인: ${input.url || ''}`; default: return `도구 호출: ${c.name}`; } } /** * @param {object} o * @param {string} o.siteDir 작업 폴더 (사이트 루트) * @param {string} o.prompt 사용자 프롬프트 * @param {string} o.systemPrompt append-system-prompt * @param {string} o.sessionId 신규 세션 ID (uuid) * @param {boolean} o.resume true 이면 --resume sessionId * @param {(ev:object)=>void} o.onEvent 진행 이벤트 콜백 * @param {string} [o.tools] 사용 가능 도구 목록 (기본: 파일 도구) * @param {boolean} [o.noPersist] 세션을 남기지 않음(기획 단계 단발 호출) * @param {(h:{cancel:Function})=>void} [o.onStart] 실행 핸들(취소 함수) 전달 */ // 저장된 세션(~/.claude/projects//.jsonl)이 이 서버에 있는지 확인. // 다른 서버에서 DB 를 옮겨온 경우 세션 파일이 없어 --resume 이 "No conversation found" 로 즉시 실패하므로, // 그런 경우 새 세션(--session-id)으로 시작한다. function sessionExists(sessionId) { if (!sessionId || !/^[0-9a-f-]{36}$/i.test(sessionId)) return false; const root = path.join(HOME, '.claude', 'projects'); try { for (const d of fs.readdirSync(root)) { if (fs.existsSync(path.join(root, d, `${sessionId}.jsonl`))) return true; } } catch (_) {} return false; } const MISSING_SESSION_RE = /No conversation found with session ID/i; function runClaude(o) { if (o.resume && o.sessionId && !sessionExists(o.sessionId)) { // v1.4.0: 오류가 아니라 정상 동작(세션 파일이 없는 서버·정리된 세션)이라 info 로 남긴다 — debug 는 관리자 오류 목록에 섞여 오류처럼 보였다 if (o.onEvent) o.onEvent({ type: 'log', level: 'info', text: `이전 세션(${o.sessionId})이 이 서버에 없어 새 세션으로 시작합니다` }); o = { ...o, resume: false, sessionId: require('crypto').randomUUID() }; } return runClaudeOnce(o).catch((err) => { // 세션 파일 목록에 있었지만 손상 등으로 이어가기에 실패한 경우에도 한 번 새 세션으로 재시도 if (o.resume && !err.cancelled && MISSING_SESSION_RE.test(err.message || '')) { if (o.onEvent) o.onEvent({ type: 'log', level: 'debug', text: '이전 세션을 이어갈 수 없어 새 세션으로 다시 시작합니다' }); return runClaudeOnce({ ...o, resume: false, sessionId: require('crypto').randomUUID() }); } throw err; }); } function runClaudeOnce(o) { return new Promise((resolve, reject) => { const model = o.model || settings.getModel(); const args = [ '-p', o.prompt, '--model', model, '--output-format', 'stream-json', '--verbose', // v1.1.6: 응답이 쓰이는 도중의 사용량(message_start 의 입력·캐시 토큰, 본문 조각)을 받아 캐시 소진을 빨리 감지한다 '--include-partial-messages', '--tools', o.tools || ALLOWED_TOOLS, '--allowedTools', o.tools || ALLOWED_TOOLS, '--settings', HOOK_SETTINGS, '--max-turns', String(MAX_TURNS), '--append-system-prompt', o.systemPrompt, ]; if (o.resume) args.push('--resume', o.sessionId); else if (o.sessionId) args.push('--session-id', o.sessionId); if (o.noPersist) args.push('--no-session-persistence'); const bin = resolveClaudeBin(false) || resolveClaudeBin(true); if (!bin) return reject(new Error(`claude 실행 파일을 찾지 못했습니다. 서버에 Claude Code 를 설치하거나 CLAUDE_BIN 을 지정하세요 (탐색 위치: ${KNOWN_DIRS.join(', ')})`)); const child = spawn(bin, args, { cwd: o.siteDir, env: cleanEnv(o.siteDir, o.authEnv), stdio: ['ignore', 'pipe', 'pipe'], detached: true, // 자체 프로세스 그룹 → 취소 시 훅/하위 프로세스까지 한꺼번에 종료 }); let result = null; let stderr = ''; let cancelled = false; let lastLive = null; const killGroup = (sig) => { try { process.kill(-child.pid, sig); } catch (_) { try { child.kill(sig); } catch (_) {} } }; // v1.6.31 (요구 3·12·25): 실행 시간 상한과 유휴 상한 — 넘으면 중단하고 이유를 결과에 남긴다. 자식 PID 를 기록해 서비스가 죽어도 다음 기동 때 정리한다(lib/jobpids.js) let watchdogReason = null; let lastOut = Date.now(); const maxTimer = setTimeout(() => { watchdogReason = `최대 실행 시간 ${MAX_JOB_MIN()}분을 넘겨 중단했습니다`; cancel(); }, MAX_JOB_MIN() * 60000); const idleTimer = setInterval(() => { if (Date.now() - lastOut > IDLE_MIN * 60000) { watchdogReason = `${IDLE_MIN}분 동안 응답이 없어 중단했습니다`; cancel(); } }, 30000); try { require('./jobpids').add(child.pid, { kind: 'claude', siteDir: o.siteDir }); } catch (_) {} const clearWatch = () => { clearTimeout(maxTimer); clearInterval(idleTimer); try { require('./jobpids').remove(child.pid); } catch (_) {} }; const cancel = () => { if (cancelled) return; cancelled = true; killGroup('SIGTERM'); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) killGroup('SIGKILL'); }, 2000); // 종료 신호가 무시되어도 결과를 기다리지 않도록 스트림을 닫는다 setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.stdout.destroy(); child.stderr.destroy(); } catch (_) {} } }, 4000); }; if (o.onStart) o.onStart({ cancel }); /** * 실시간 사용량 * seen : 메시지 ID → usage. 완성된 assistant 메시지의 usage 가 오면 그 값으로 확정, 그 전에는 message_start 의 usage(입력·캐시) 로 미리 채운다. * partial : 쓰는 중인 메시지의 출력 토큰 추정(본문 조각 글자 수 ÷ 3). 메시지가 완성되면 0 으로. * emitLive 는 조각마다 부르면 이벤트가 너무 많으므로 0.8초 간격으로 묶는다(확정 시에는 즉시). */ const seen = new Map(); let thinkingEst = 0; let partial = 0; let curMsg = null; let liveTimer = null; let lastEmit = 0; const emitLive = (force) => { const now = Date.now(); if (!force && now - lastEmit < 800) { if (!liveTimer) liveTimer = setTimeout(() => { liveTimer = null; emitLive(true); }, 800); return; } lastEmit = now; if (liveTimer) { clearTimeout(liveTimer); liveTimer = null; } const u = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 }; for (const x of seen.values()) { u.input += x.input_tokens || 0; u.output += x.output_tokens || 0; u.cacheCreate += x.cache_creation_input_tokens || 0; u.cacheRead += x.cache_read_input_tokens || 0; } u.output += thinkingEst + partial; u.total = u.input + u.output + u.cacheCreate + u.cacheRead; u.thinking = thinkingEst; lastLive = u; emit({ type: 'usage', live: true, usage: u, calls: seen.size }); }; const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) { /* ignore */ } }; child.on('error', (err) => { if (err.code === 'ENOENT') resolveClaudeBin(true); reject(new Error(`claude 실행 실패 (${bin}): ${err.message}`)); }); child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 20000) stderr = stderr.slice(-20000); }); child.stdout.on('data', () => { lastOut = Date.now(); }); // v1.6.31 유휴 감시 const rl = readline.createInterface({ input: child.stdout }); rl.on('line', (line) => { line = line.trim(); if (!line) return; let ev; try { ev = JSON.parse(line); } catch (_) { return; } switch (ev.type) { case 'stream_event': { // 부분 메시지 (--include-partial-messages): 시작 시 입력·캐시 토큰, 진행 중 본문 조각, 끝에 출력 토큰 const e = ev.event || {}; if (e.type === 'message_start' && e.message) { curMsg = e.message.id || `m${seen.size}`; seen.set(curMsg, { ...(e.message.usage || {}), output_tokens: 0 }); partial = 0; emitLive(); } else if (e.type === 'content_block_delta' && e.delta) { const t = e.delta.text || e.delta.partial_json || e.delta.thinking || ''; if (t) { partial += t.length / 3; emitLive(); } } else if (e.type === 'message_delta' && e.usage && curMsg && seen.has(curMsg)) { seen.get(curMsg).output_tokens = e.usage.output_tokens || 0; partial = 0; emitLive(true); } break; } case 'system': if (ev.subtype === 'init') { emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${ev.model || model})` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' }); } else if (ev.subtype === 'thinking_tokens' && typeof ev.estimated_tokens === 'number') { thinkingEst = Math.max(thinkingEst, ev.estimated_tokens); emitLive(); emit({ type: 'progress', text: '내용을 구상하고 있습니다…' }); } break; case 'assistant': { if (ev.message && ev.message.usage) { seen.set(ev.message.id || String(seen.size), ev.message.usage); thinkingEst = 0; partial = 0; emitLive(true); } const content = (ev.message && ev.message.content) || []; for (const c of content) { if (c.type === 'text' && c.text && c.text.trim()) { const t = c.text.replace(/[\s\S]*?<\/brief>/g, '[Brief 갱신됨]').trim(); if (t) { emit({ type: 'log', level: 'assistant', text: t }); emit({ type: 'progress', text: '결과를 정리하고 있습니다' }); } } else if (c.type === 'tool_use') { emit({ type: 'log', level: 'tool', text: summarizeToolUse(c) }); emit({ type: 'progress', text: friendlyProgress(c) }); } } break; } case 'user': { const content = (ev.message && ev.message.content); if (Array.isArray(content)) { for (const c of content) { if (c.type === 'tool_result' && c.is_error) { const t = typeof c.content === 'string' ? c.content : JSON.stringify(c.content); emit({ type: 'log', level: 'debug', text: `도구 오류: ${String(t).slice(0, 300)}` }); } } } break; } case 'result': result = ev; break; default: break; } }); child.on('close', (code) => { clearWatch(); // v1.6.31 감시 타이머·PID 기록 정리 if (liveTimer) { clearTimeout(liveTimer); liveTimer = null; } // 종료 뒤에 늦은 사용량 이벤트가 나가지 않게 if (result) result.__model = model; if ((cancelled || !result) && !o.noPersist) markSessionDirty(o.sessionId); // v1.4.5 누적값을 받지 못한 실행 — 다음 실행은 가격표로 계산 if (cancelled) return reject(Object.assign(new Error(watchdogReason || '사용자가 작업을 중단했습니다'), { cancelled: true, watchdog: !!watchdogReason, usage: lastLive || { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 } })); if (!result) { return reject(new Error(`claude 종료 코드 ${code}, 결과 없음. ${stderr.trim().slice(-800)}`)); } const nr = normalizeResult(result, o); if (!nr.ok && !nr.errorText && stderr.trim()) nr.errorText = stderr.trim().slice(-800); resolve(nr); }); }); } function normalizeResult(r, o = {}) { const u = r.usage || {}; const cc = u.cache_creation || {}; const usage = { input: u.input_tokens || 0, output: u.output_tokens || 0, cacheCreate: u.cache_creation_input_tokens || 0, cacheRead: u.cache_read_input_tokens || 0, thinking: (u.output_tokens_details && u.output_tokens_details.thinking_tokens) || 0, // v1.4.5 캐시 쓰기 종류별 토큰 (1시간 캐시는 입력 단가의 2배, 5분 캐시는 1.25배 — Claude Code 는 1시간 캐시를 쓴다) cacheCreate1h: cc.ephemeral_1h_input_tokens || 0, cacheCreate5m: cc.ephemeral_5m_input_tokens || 0, }; usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; const model = r.__model || Object.keys(r.modelUsage || {})[0] || MODEL; const cost = runCost(r, usage, model, o); // v1.4.5 이번 실행 비용 (세션 누적값이 아님) return { model, ok: !r.is_error && r.subtype === 'success', subtype: r.subtype, isError: !!r.is_error, resultText: typeof r.result === 'string' ? r.result : '', errorText: (!r.is_error && r.subtype === 'success') ? '' : [typeof r.result === 'string' ? r.result : '', ...(Array.isArray(r.errors) ? r.errors : [])].filter(Boolean).join(' / ').slice(0, 800), sessionId: r.session_id, usage, costUsd: cost.costUsd, // 이번 실행 비용 (청구 기준) costSource: cost.costSource, // single(새 세션·한 번짜리) | delta(누적 차이) | priced(이번 토큰 × 가격표) costCumulativeUsd: cost.cumulativeUsd, // CLI 가 알려 준 세션 누적 비용 (참고용) costPricedUsd: cost.pricedUsd, // 이번 토큰 × 가격표 (확인용) numTurns: r.num_turns || 0, durationMs: r.duration_ms || 0, modelUsage: cost.modelUsage, // 이번 실행분 모델별 사용량 (v1.4.5 전에는 세션 누적이었음) permissionDenials: r.permission_denials || [], raw: r, }; } module.exports = { runClaude, sessionExists, MODEL, currentModel: () => settings.getModel(), claudeInfo, resolveClaudeBin };