Files
AriWeb-Suite/modules/webmaker/lib/claude.js

362 lines
25 KiB
JavaScript
Raw Permalink 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.
'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/<cwd 인코딩>/<id>.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(/<brief>[\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 };