362 lines
25 KiB
JavaScript
362 lines
25 KiB
JavaScript
'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 };
|