HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+69
-21
@@ -14,23 +14,34 @@
|
||||
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
|
||||
*/
|
||||
import { spawn, execFile } from 'node:child_process';
|
||||
import { accessSync, constants, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { accessSync, constants, statSync, realpathSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join, basename, dirname } from 'node:path';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } }
|
||||
/**
|
||||
* 공급자 프리셋(0.3.2). kind 가 실제 HTTP 형식(anthropic·openai·gemini)이고, 프리셋은 기본 주소·인증 방식·사전 준비 안내(guide)·OAuth 프리셋을 더한다.
|
||||
* - anthropic(Claude) · openai(ChatGPT) · gemini(Google) · openrouter(OpenRouter, OpenAI 호환 고정 주소, PKCE 로 키 발급) · other(기타 OpenAI 호환 서비스, 기본 주소 필수) · local-cli
|
||||
*/
|
||||
export const PROVIDERS = {
|
||||
anthropic: { label: 'Anthropic(Claude)', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true },
|
||||
openai: { label: 'OpenAI 호환', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', authKinds: ['login'], defaultBase: null, tools: false },
|
||||
anthropic: { label: 'Claude(Anthropic)', kind: 'anthropic', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true, guide: { keyUrl: 'https://console.anthropic.com/settings/keys', steps: ['Anthropic Console 에서 API 키를 발급받아 아래에 붙여 넣으세요(저장 뒤 다시 표시하지 않음).', '기본 모델 예: claude-opus-5-5 · claude-sonnet-5-5 — "확인"을 누르면 실제 모델 목록을 받아 옵니다.'], oauth: 'Anthropic 은 공개 OAuth 클라이언트를 제공하지 않습니다 — 자체 OAuth 중계가 있을 때만 authorize/token 주소와 client id 를 적으세요.' } },
|
||||
openai: { label: 'ChatGPT(OpenAI)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true, guide: { keyUrl: 'https://platform.openai.com/api-keys', steps: ['OpenAI Platform 에서 API 키를 발급받아 붙여 넣으세요.', 'ChatGPT 구독 로그인(Codex)은 "로컬 CLI 로그인 세션"으로 연결합니다.'], oauth: 'OpenAI API 는 공개 OAuth 를 제공하지 않습니다 — 자체 중계가 있을 때만 사용.' } },
|
||||
gemini: { label: 'Gemini(Google)', kind: 'gemini', authKinds: ['api_key', 'oauth'], defaultBase: 'https://generativelanguage.googleapis.com/v1beta', tools: true, guide: { keyUrl: 'https://aistudio.google.com/app/apikey', steps: ['Google AI Studio 에서 API 키를 만들어 붙여 넣으세요.', '기본 모델 예: gemini-2.5-pro · gemini-2.5-flash.'], oauth: 'Google Cloud Console 에서 OAuth 클라이언트(웹 애플리케이션)를 만들고 승인된 리디렉션 URI 에 HAL 콜백 주소를 등록한 뒤 client id·secret 을 적으세요. Generative Language API 가 켜져 있어야 합니다. 저장하면 Google 로그인 창이 자동으로 열립니다.' }, oauthPreset: { authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', tokenUrl: 'https://oauth2.googleapis.com/token', scopes: ['https://www.googleapis.com/auth/generative-language'], extra: { access_type: 'offline', prompt: 'consent' } } },
|
||||
openrouter: { label: 'OpenRouter', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: 'https://openrouter.ai/api/v1', tools: true, guide: { keyUrl: 'https://openrouter.ai/keys', steps: ['OpenRouter 키를 붙여 넣거나, OAuth(PKCE)를 고르면 저장 직후 OpenRouter 로그인 창이 열리고 승인 뒤 키가 자동 발급·저장됩니다.', '모델 이름은 공급자/모델 형식(예: anthropic/claude-sonnet-4.5, openai/gpt-5).'], oauth: 'OpenRouter OAuth(PKCE)는 설정값이 필요 없습니다 — 저장하면 로그인 창이 자동으로 열립니다.' }, oauthPreset: { mode: 'openrouter_pkce', authorizeUrl: 'https://openrouter.ai/auth', tokenUrl: 'https://openrouter.ai/api/v1/auth/keys', scopes: [] } },
|
||||
other: { label: '기타 API 서비스(OpenAI 호환)', kind: 'openai', authKinds: ['api_key', 'oauth'], defaultBase: null, tools: true, guide: { keyUrl: null, steps: ['OpenAI Chat Completions 호환 API(예: Groq, Together, Mistral, DeepSeek, 자체 vLLM)의 기본 주소(…/v1)와 키를 적으세요.', '도구 호출(function calling)을 지원하는 모델이어야 HAL 도구를 쓸 수 있습니다.'], oauth: '서비스가 OAuth2 authorization code(PKCE) 를 지원할 때 authorize/token 주소·client id·scope 를 적으세요.' } },
|
||||
'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code·Codex)', kind: 'cli', authKinds: ['login'], defaultBase: null, tools: false, guide: { keyUrl: null, steps: ['HAL 서버에 설치된 CLI(claude·codex)를 자동 탐색합니다. 로그인이 안 되어 있으면 "텍스트 셸"에서 login 을 실행하세요(브라우저 인증 URL 과 코드 입력).', 'CLI 연결에는 HAL MCP 서버 설정이 자동으로 붙어 읽기 도구를 바로 쓰고, 쓰기·고위험 도구는 승인 요청으로 올라갑니다.'], oauth: null } },
|
||||
};
|
||||
const base = (conn) => String(conn.baseUrl || PROVIDERS[conn.provider].defaultBase || '').replace(/\/+$/, '');
|
||||
export const providerKind = (provider) => (PROVIDERS[provider] || {}).kind || provider;
|
||||
const base = (conn) => String(conn.baseUrl || (PROVIDERS[conn.provider] || {}).defaultBase || '').replace(/\/+$/, '');
|
||||
const fetchOf = (d) => d.fetch || fetch;
|
||||
async function readJson(r) { try { return await r.json(); } catch (_) { return null; } }
|
||||
function authHeaders(conn, secret) {
|
||||
if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 });
|
||||
if (conn.provider === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
|
||||
return { Authorization: `Bearer ${secret.value}` };
|
||||
const kind = providerKind(conn.provider);
|
||||
if (kind === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' };
|
||||
if (kind === 'gemini') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}` } : { 'x-goog-api-key': secret.value };
|
||||
const h = { Authorization: `Bearer ${secret.value}` }; if (conn.provider === 'openrouter') { h['HTTP-Referer'] = 'https://hal.ariweb.net'; h['X-Title'] = 'Ariweb HAL'; } return h;
|
||||
}
|
||||
function providerFail(status, j) { const e = j && j.error ? (typeof j.error === 'string' ? j.error : `${j.error.type || j.error.code || ''}: ${j.error.message || ''}`) : ''; return new ProviderError(`provider_${status}`, `공급자 오류 ${status}${e ? ' ' + e : ''}`.trim(), { status: status === 401 || status === 403 ? 502 : 502 }); }
|
||||
|
||||
@@ -39,17 +50,43 @@ export async function listModels(d, conn, secret) {
|
||||
if (conn.provider === 'local-cli') { const info = await cliInfo(conn); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 '${info.loginCommand}' 을 실행하세요. HAL 이 대신 로그인할 수 없습니다`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
|
||||
const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) });
|
||||
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j);
|
||||
const models = ((j && j.data) || []).map((m) => m.id).filter(Boolean);
|
||||
const models = providerKind(conn.provider) === 'gemini' ? ((j && j.models) || []).map((m) => String(m.name || '').replace(/^models\//, '')).filter(Boolean) : ((j && j.data) || []).map((m) => m.id).filter(Boolean);
|
||||
return { models };
|
||||
}
|
||||
|
||||
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
|
||||
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096 }) {
|
||||
if (conn.provider === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (conn.provider === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (conn.provider === 'local-cli') return chatCli(conn, { system, messages });
|
||||
export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096, mcp = false }) {
|
||||
const kind = providerKind(conn.provider);
|
||||
if (kind === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'gemini') return chatGemini(d, conn, secret, { system, messages, tools, maxTokens });
|
||||
if (kind === 'cli') return chatCli(d, conn, { system, messages, mcp });
|
||||
throw new ProviderError('bad_provider', `알 수 없는 공급자: ${conn.provider}`, { status: 400 });
|
||||
}
|
||||
/** 공급자 형식 이름: toolDefs(format) 에 넘길 값 */
|
||||
export const toolFormat = (provider) => ({ anthropic: 'anthropic', openai: 'openai', gemini: 'gemini' }[providerKind(provider)] || 'anthropic');
|
||||
// ---------------------------------------------------------------- Gemini(generateContent)
|
||||
/** Gemini functionDeclarations 는 OpenAPI 부분집합(type 은 대문자, additionalProperties 없음) */
|
||||
export function geminiSchema(sc) {
|
||||
if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required;
|
||||
if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out;
|
||||
}
|
||||
function toGemini(messages) {
|
||||
const out = [];
|
||||
for (const m of messages) {
|
||||
const parts = []; for (const b of m.content) { if (b.type === 'text' && b.text) parts.push({ text: b.text }); else if (b.type === 'tool_use') parts.push({ functionCall: { name: b.name, args: b.input || {} } }); else if (b.type === 'tool_result') { let resp; try { resp = JSON.parse(String(b.output)); if (!resp || typeof resp !== 'object' || Array.isArray(resp)) resp = { output: resp }; } catch (_) { resp = { output: String(b.output ?? '') }; } if (b.ok === false) resp = { error: true, ...resp }; parts.push({ functionResponse: { name: b.name, response: resp } }); } }
|
||||
if (!parts.length) continue; const role = m.role === 'assistant' ? 'model' : 'user'; const last = out[out.length - 1]; if (last && last.role === role) last.parts.push(...parts); else out.push({ role, parts });
|
||||
}
|
||||
if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', parts: [{ text: '(대화 시작)' }] });
|
||||
return out;
|
||||
}
|
||||
async function chatGemini(d, conn, secret, { system, messages, tools, maxTokens }) {
|
||||
const body = { systemInstruction: { parts: [{ text: system }] }, contents: toGemini(messages), generationConfig: { maxOutputTokens: maxTokens } }; if (tools.length) body.tools = [{ functionDeclarations: tools }];
|
||||
const r = await fetchOf(d)(`${base(conn)}/models/${encodeURIComponent(conn.model)}:generateContent`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) });
|
||||
const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const cand = j && j.candidates && j.candidates[0]; if (!cand || !cand.content) throw new ProviderError('bad_response', j && j.promptFeedback ? `공급자가 응답을 막았습니다(${j.promptFeedback.blockReason || 'blocked'})` : '공급자 응답 형식이 올바르지 않습니다');
|
||||
const blocks = []; let n = 0; for (const p of cand.content.parts || []) { if (p.text) blocks.push({ type: 'text', text: String(p.text) }); if (p.functionCall) blocks.push({ type: 'tool_use', id: `gfc_${Date.now().toString(36)}_${n++}`, name: p.functionCall.name, input: p.functionCall.args || {} }); }
|
||||
return { blocks, usage: { inputTokens: j.usageMetadata?.promptTokenCount ?? null, outputTokens: j.usageMetadata?.candidatesTokenCount ?? null }, stopReason: cand.finishReason || null, model: conn.model };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Anthropic
|
||||
function toAnthropic(messages) {
|
||||
@@ -102,7 +139,7 @@ export const CLI_TOOLS = {
|
||||
};
|
||||
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
|
||||
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
|
||||
const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
||||
export const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
||||
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
|
||||
export function cliSearchDirs() {
|
||||
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
|
||||
@@ -157,14 +194,25 @@ function spawnCollect(bin, args, { cwd, timeoutMs }) {
|
||||
child.on('close', (code) => { clearTimeout(timer); resolve({ code, out, err }); });
|
||||
});
|
||||
}
|
||||
function chatCli(conn, { system, messages }) {
|
||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
||||
return tool === 'codex' ? chatCodex(conn, bin, { system, messages }) : chatClaude(conn, bin, { system, messages });
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
/** [0.3.2] HAL MCP 서버 설정(로컬 CLI 연결에 자동으로 붙이고, AI 연동 화면에서 복사용으로도 보인다). 비밀 없음 — 데이터 경로만. */
|
||||
export function mcpConfig(d) {
|
||||
const index = join(ROOT, 'hal', 'index.mjs'); const env = { ARIWEB_HAL_DATA: d.config.dataDir };
|
||||
const claude = { mcpServers: { hal: { command: process.execPath, args: [index, 'mcp'], env } } };
|
||||
const codexArgs = ['-c', `mcp_servers.hal.command=${JSON.stringify(process.execPath)}`, '-c', `mcp_servers.hal.args=${JSON.stringify([index, 'mcp'])}`, '-c', `mcp_servers.hal.env={ARIWEB_HAL_DATA=${JSON.stringify(d.config.dataDir)}}`];
|
||||
const codexToml = `[mcp_servers.hal]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([index, 'mcp'])}\n\n[mcp_servers.hal.env]\nARIWEB_HAL_DATA = ${JSON.stringify(d.config.dataDir)}\n`;
|
||||
return { claude, codexArgs, codexToml, claudeCommand: `claude --mcp-config <hal-mcp.json 경로> --strict-mcp-config --allowedTools mcp__hal`, note: 'HAL MCP 서버는 읽기 도구만 바로 실행하고, 쓰기·고위험 도구는 운영자 승인 요청으로 올립니다(hal_action_status 로 확인).' };
|
||||
}
|
||||
async function chatClaude(conn, bin, { system, messages }) {
|
||||
function chatCli(d, conn, { system, messages, mcp }) {
|
||||
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
||||
return tool === 'codex' ? chatCodex(d, conn, bin, { system, messages, mcp }) : chatClaude(d, conn, bin, { system, messages, mcp });
|
||||
}
|
||||
async function chatClaude(d, conn, bin, { system, messages, mcp }) {
|
||||
const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
|
||||
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || CLI_TOOLS.claude.defaultModel, '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
const { code, out, err } = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 });
|
||||
let mcpFile = null; if (mcp) { const dir = mkdtempSync(join(tmpdir(), 'hal-mcp-')); mcpFile = join(dir, 'hal-mcp.json'); writeFileSync(mcpFile, JSON.stringify(mcpConfig(d).claude), { mode: 0o600 }); args.push('--mcp-config', mcpFile, '--strict-mcp-config', '--allowedTools', 'mcp__hal'); } // [0.3.2] HAL 도구를 MCP 로 바로 쓰게 한다
|
||||
let res; try { res = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { if (mcpFile) { try { rmSync(dirname(mcpFile), { recursive: true, force: true }); } catch (_) {} } }
|
||||
const { code, out, err } = res;
|
||||
let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} }
|
||||
if (code !== 0 && !j) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
|
||||
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
|
||||
@@ -172,9 +220,9 @@ async function chatClaude(conn, bin, { system, messages }) {
|
||||
return { blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: 'end_turn', model: conn.model, sessionId: j.session_id || null };
|
||||
}
|
||||
/** codex exec --json: 읽기 전용 샌드박스·임시 작업 폴더·세션 비보존. JSONL 의 item.completed(agent_message) 가 답, turn.completed.usage 가 사용량. */
|
||||
async function chatCodex(conn, bin, { system, messages }) {
|
||||
async function chatCodex(d, conn, bin, { system, messages, mcp }) {
|
||||
const prompt = `${system}\n\n---\n${transcriptText(messages)}\n\n위 대화의 마지막 발언에 답하라.`; const dir = mkdtempSync(join(tmpdir(), 'hal-codex-'));
|
||||
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
||||
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), ...(mcp ? mcpConfig(d).codexArgs : []), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])]; // [0.3.2] -c mcp_servers.hal.* 로 HAL 도구 제공
|
||||
let res; try { res = await spawnCollect(bin, args, { cwd: dir, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { try { rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
|
||||
let text = ''; let usage = { inputTokens: null, outputTokens: null }; let errText = '';
|
||||
for (const line of res.out.split('\n')) { let ev; try { ev = JSON.parse(line); } catch (_) { continue; } if (!ev || typeof ev !== 'object') continue; if (ev.type === 'item.completed' && ev.item && ev.item.type === 'agent_message' && ev.item.text) text = String(ev.item.text).trim(); else if (ev.type === 'turn.completed' && ev.usage) usage = { inputTokens: ev.usage.input_tokens ?? null, outputTokens: ev.usage.output_tokens ?? null }; else if (ev.type === 'error') errText = String(ev.message || ev.error || JSON.stringify(ev)).slice(0, 300); }
|
||||
|
||||
Reference in New Issue
Block a user