#!/usr/bin/env node
// Claude Agent SDK 러너(v2) — designpaca 인터뷰 게이트 평가.
//
// 기존 run-claude.mjs(`claude -p` CLI 직접 호출)는 남겨 둔다. 이 러너는
// 오케스트레이터 지시로 추가한 SDK 버전이다: 실측으로 확인했듯 이 환경에서는
// @anthropic-ai/claude-agent-sdk 의 query() 가 이미 로그인된 `claude` CLI를
// 서브프로세스로 띄워 그대로 동작한다(공식 quickstart의 ANTHROPIC_API_KEY
// 요구사항과 별개로, CLI 로그인 세션을 그대로 탄다 — apiKeySource: "none").
//
// canUseTool 로 AskUserQuestion 호출을 가로챈다: 사용자를 기다리는 지점이므로
// deny 하고 그 턴을 끝낸다(abort). 그 밖의 도구는 허용하고 기록한다. 텍스트
// 질문은 죽이지 않고 턴이 끝날 때까지 관찰한다. 질문 여부·턴 종료·슬롯 판정은
// run.mjs 가 lib/judge.mjs 로 원본 스트림을 다시 읽어 내린다.
//
// 설치(1회):
// cd outputs/eval/deps && npm init -y && npm install @anthropic-ai/claude-agent-sdk
// (저장소 의존성을 늘리지 않으려고 outputs/eval/deps/ 에 따로 설치한다.)
//
// 단독 실행:
// node build/eval/interview/run-claude-sdk.mjs \
// --workdir <절대경로> --scenario S1 --prompt "..." \
// --model sonnet --permission-mode auto --out result.json --log raw.jsonl
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs";
import { readFollowUps } from "./lib/followups.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
const SDK_ENTRY = path.join(REPO_ROOT, "outputs", "eval", "deps", "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("--")) {
out[key] = true;
} else {
out[key] = next;
i++;
}
}
}
return out;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const workdir = args.workdir;
const scenario = args.scenario || "unknown";
const prompt = args.prompt;
const model = args.model || "sonnet";
const permissionMode = args["permission-mode"] || "default"; // "auto" | "default"
const timeoutMs = Number(args["timeout-ms"] || 300000);
const outPath = args.out;
const logPath = args.log;
const followUps = readFollowUps(args["follow-ups"]);
if (!workdir || !prompt || !outPath) {
console.error("사용법: run-claude-sdk.mjs --workdir
--scenario --prompt --out [--log ] [--model ] [--permission-mode auto|default] [--timeout-ms ] [--follow-ups ]");
process.exit(2);
}
if (!fs.existsSync(SDK_ENTRY)) {
console.error(`SDK가 설치되어 있지 않다: ${SDK_ENTRY}\ncd outputs/eval/deps && npm install @anthropic-ai/claude-agent-sdk 로 먼저 설치할 것.`);
process.exit(2);
}
const { query } = await import(pathToFileURL(SDK_ENTRY).href);
const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null;
const startedAt = Date.now();
const result = {
scenario,
runner: "claude-sdk",
model: null,
modelRequested: model,
permissionMode,
permissionModeRejected: false,
sessionId: null,
turnsRun: 0,
finalMessageText: null,
toolsUsed: [],
initTools: null,
askUserQuestionAvailable: null,
initSkills: null,
initPlugins: null,
claudeCodeVersion: null,
apiKeySource: null,
durationMs: null,
killedReason: null,
error: null,
};
// 한 턴 = query() 한 번. 구조화 질문 도구 호출은 사용자를 기다리는 지점이므로 거기서 턴이 끝난 것으로
// 보고 deny + abort 한다. 텍스트 질문은 죽이지 않고 턴이 끝날 때까지 관찰한다(질문 뒤 같은 턴의 도구
// 호출·파일 쓰기를 측정하기 위해서다, 01 F13). 후속 답은 resume 으로 같은 세션에 이어 보낸다.
async function runTurn(text, resumeId) {
const controller = new AbortController();
let finished = false;
const timer = setTimeout(() => {
if (!finished) {
result.killedReason = "timeout";
controller.abort();
}
}, timeoutMs);
const snapshotBefore = snapshotDir(workdir);
const canUseTool = async (toolName, input) => {
if (logStream) logStream.write(JSON.stringify({ type: "canUseTool", toolName, input }) + "\n");
result.toolsUsed.push(toolName);
if (toolName === "AskUserQuestion") {
// 답은 다음 턴(resume)의 사용자 메시지로 보낸다. abort 는 이 콜백이 돌아간 뒤에 건다.
setTimeout(() => controller.abort(), 50);
// interrupt: abort 만으로는 턴이 바로 끝나지 않았다(실측 redteam-2026-10-02 S11-2: deny 오류를 본 모델이
// 5초 뒤 ToolSearch 를 더 호출했고 판정기가 "카드 뒤 도구"로 집계). 실제 Claude Code 에서는 이 지점에서
// 사용자를 기다리므로 SDK 의 deny interrupt 로 그 자리에서 실행을 멈춘다.
return { behavior: "deny", message: "평가 하네스: 사용자 답은 다음 메시지로 온다.", interrupt: true };
}
return { behavior: "allow", updatedInput: input };
};
try {
for await (const message of query({
prompt: text,
options: {
cwd: workdir,
model,
permissionMode,
settingSources: ["project"],
systemPrompt: { type: "preset", preset: "claude_code" },
abortController: controller,
maxTurns: 40,
canUseTool,
...(resumeId ? { resume: resumeId } : {}),
},
})) {
if (logStream) logStream.write(JSON.stringify(message).slice(0, 20000) + "\n");
if (message.type === "system" && message.subtype === "init") {
result.model = result.model || message.model || null;
result.sessionId = result.sessionId || message.session_id || null;
result.initTools = result.initTools || message.tools || null;
result.askUserQuestionAvailable = Array.isArray(message.tools) ? message.tools.includes("AskUserQuestion") : result.askUserQuestionAvailable;
result.initSkills = result.initSkills || message.skills || null;
result.initPlugins = result.initPlugins || message.plugins || null;
result.claudeCodeVersion = message.claude_code_version || result.claudeCodeVersion;
result.apiKeySource = message.apiKeySource || result.apiKeySource;
} else if (message.type === "result") {
result.finalMessageText = message.result || null;
result.sessionId = result.sessionId || message.session_id || null;
}
}
} catch (e) {
const msg = String(e && e.message ? e.message : e);
const isAbort = e && (e.name === "AbortError" || /abort/i.test(msg));
if (!isAbort) {
if (/permission[_-]?mode/i.test(msg) || /auto/i.test(msg)) result.permissionModeRejected = true;
result.error = msg;
result.errorStack = e && e.stack ? String(e.stack).slice(0, 2000) : null;
}
}
finished = true;
clearTimeout(timer);
const changed = diffSnapshots(snapshotBefore, snapshotDir(workdir));
if (logStream) logStream.write(JSON.stringify({ type: "eval_turn_end", filesChanged: [...changed.added, ...changed.modified] }) + "\n");
result.turnsRun++;
}
await runTurn(prompt, null);
for (const answer of followUps) {
if (result.error || result.killedReason === "timeout" || !result.sessionId) break;
if (logStream) logStream.write(JSON.stringify({ type: "eval_user", text: answer, via: "resume" }) + "\n");
await runTurn(answer, result.sessionId);
}
if (logStream) await new Promise((r) => logStream.end(r));
result.durationMs = Date.now() - startedAt;
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
}
main().catch((err) => {
console.error("run-claude-sdk.mjs 실패:", err);
process.exit(1);
});