HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
/**
* hal/ai/providers.mjs — 모델 공급자 어댑터(0.3.0). 엔진(hal/ai/engine.mjs)은 공급자와 무관한 블록 형식으로 말하고, 여기서 공급자별 HTTP 형식으로 바꾼다.
*
* 공급자
* - anthropic : Messages API(POST {base}/messages, GET {base}/models). API 키는 `x-api-key`, OAuth 는 `Authorization: Bearer`.
* - openai : OpenAI 호환 Chat Completions(POST {base}/chat/completions, GET {base}/models). base_url 을 바꾸면 호환 공급자도 쓴다. 키·토큰은 Bearer.
2026-10-29 01:11:32 +00:00
* - local-cli : 서버에 설치된 CLI 의 **로그인 세션** — Claude Code(`claude -p … --output-format json`) 또는 OpenAI Codex(`codex exec --json -s read-only …`). [0.3.1] 설치 위치를 자동 탐색한다(아래).
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
* CLI 에는 HAL 도구를 넘기지 않는다(대화·분석 전용). 세션 파일·자격은 HAL 이 읽지도 전달하지도 않는다. 환경 변수는 PATH·HOME 등 최소만 넘긴다(Vault 키 등 HAL 환경 비노출).
*
* 공통 블록 형식
* messages: [{ role:'user'|'assistant', content:[ {type:'text',text} | {type:'tool_use',id,name,input} | {type:'tool_result',toolUseId,name,ok,output} ] }]
* 응답: { blocks:[text|tool_use], usage:{inputTokens,outputTokens}, stopReason, model }
*
* 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다.
*/
import { spawn , execFile } from 'node:child_process' ;
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건 통과
2026-10-29 01:43:02 +00:00
import { accessSync , constants , statSync , realpathSync , readFileSync , writeFileSync , mkdtempSync , rmSync } from 'node:fs' ;
import { join , basename , dirname } from 'node:path' ;
2026-10-29 01:11:32 +00:00
import { homedir , tmpdir } from 'node:os' ;
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건 통과
2026-10-29 01:43:02 +00:00
import { fileURLToPath } from 'node:url' ;
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
export class ProviderError extends Error { constructor ( code , message , { status = 502 , detail } = {}) { super ( message ); this . code = code ; this . status = status ; this . detail = detail ; } }
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건 통과
2026-10-29 01:43:02 +00:00
/**
* 공급자 프리셋(0.3.2). kind 가 실제 HTTP 형식(anthropic·openai·gemini)이고, 프리셋은 기본 주소·인증 방식·사전 준비 안내(guide)·OAuth 프리셋을 더한다.
* - anthropic(Claude) · openai(ChatGPT) · gemini(Google) · openrouter(OpenRouter, OpenAI 호환 고정 주소, PKCE 로 키 발급) · other(기타 OpenAI 호환 서비스, 기본 주소 필수) · local-cli
*/
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
export const PROVIDERS = {
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건 통과
2026-10-29 01:43:02 +00:00
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 } },
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
};
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건 통과
2026-10-29 01:43:02 +00:00
export const providerKind = ( provider ) => ( PROVIDERS [ provider ] || {}). kind || provider ;
const base = ( conn ) => String ( conn . baseUrl || ( PROVIDERS [ conn . provider ] || {}). defaultBase || '' ). replace ( /\/+$/ , '' );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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 });
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건 통과
2026-10-29 01:43:02 +00:00
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 ;
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
}
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 }); }
/** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */
export async function listModels ( d , conn , secret ) {
2026-10-29 01:11:32 +00:00
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 }; }
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
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 );
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건 통과
2026-10-29 01:43:02 +00:00
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 );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
return { models };
}
/** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */
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건 통과
2026-10-29 01:43:02 +00:00
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 });
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
throw new ProviderError ( 'bad_provider' , `알 수 없는 공급자: ${ conn . provider } ` , { status : 400 });
}
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건 통과
2026-10-29 01:43:02 +00:00
/** 공급자 형식 이름: 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 };
}
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
// ---------------------------------------------------------------- Anthropic
function toAnthropic ( messages ) {
const out = [];
for ( const m of messages ) {
const content = m . content . map (( b ) => b . type === 'text' ? { type : 'text' , text : b . text } : b . type === 'tool_use' ? { type : 'tool_use' , id : b . id , name : b . name , input : b . input || {} } : { type : 'tool_result' , tool_use_id : b . toolUseId , content : String ( b . output ?? '' ), is_error : b . ok === false }). filter (( b ) => b . type !== 'text' || b . text );
if ( ! content . length ) continue ;
const role = m . content . some (( b ) => b . type === 'tool_result' ) ? 'user' : m . role ;
const last = out [ out . length - 1 ]; if ( last && last . role === role ) last . content . push (... content ); else out . push ({ role , content }); // 같은 역할이 이어지면 합친다(교대 규칙)
}
if ( out . length && out [ 0 ]. role !== 'user' ) out . unshift ({ role : 'user' , content : [{ type : 'text' , text : '(대화 시작)' }] });
return out ;
}
async function chatAnthropic ( d , conn , secret , { system , messages , tools , maxTokens }) {
const body = { model : conn . model , max_tokens : maxTokens , system , messages : toAnthropic ( messages ) }; if ( tools . length ) body . tools = tools ;
const r = await fetchOf ( d )( ` ${ base ( conn ) } /messages` , { 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 ); if ( ! j || ! Array . isArray ( j . content )) throw new ProviderError ( 'bad_response' , '공급자 응답 형식이 올바르지 않습니다' );
const blocks = j . content . map (( b ) => b . type === 'text' ? { type : 'text' , text : b . text || '' } : b . type === 'tool_use' ? { type : 'tool_use' , id : b . id , name : b . name , input : b . input || {} } : null ). filter ( Boolean );
return { blocks , usage : { inputTokens : j . usage ? . input_tokens ?? null , outputTokens : j . usage ? . output_tokens ?? null }, stopReason : j . stop_reason || null , model : j . model || conn . model };
}
// ---------------------------------------------------------------- OpenAI 호환
function toOpenai ( system , messages ) {
const out = [{ role : 'system' , content : system }];
for ( const m of messages ) {
const tr = m . content . filter (( b ) => b . type === 'tool_result' ); for ( const b of tr ) out . push ({ role : 'tool' , tool_call_id : b . toolUseId , content : String ( b . output ?? '' ) });
const text = m . content . filter (( b ) => b . type === 'text' ). map (( b ) => b . text ). join ( '\n' ); const calls = m . content . filter (( b ) => b . type === 'tool_use' );
if ( m . role === 'assistant' ) { if ( text || calls . length ) out . push ({ role : 'assistant' , content : text || null , ...( calls . length ? { tool_calls : calls . map (( c ) => ({ id : c . id , type : 'function' , function : { name : c . name , arguments : JSON . stringify ( c . input || {}) } })) } : {}) }); }
else if ( text ) out . push ({ role : 'user' , content : text });
}
return out ;
}
async function chatOpenai ( d , conn , secret , { system , messages , tools , maxTokens }) {
const body = { model : conn . model , max_tokens : maxTokens , messages : toOpenai ( system , messages ) }; if ( tools . length ) body . tools = tools ;
const r = await fetchOf ( d )( ` ${ base ( conn ) } /chat/completions` , { 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 msg = j && j . choices && j . choices [ 0 ] && j . choices [ 0 ]. message ; if ( ! msg ) throw new ProviderError ( 'bad_response' , '공급자 응답 형식이 올바르지 않습니다' );
const blocks = []; if ( msg . content ) blocks . push ({ type : 'text' , text : String ( msg . content ) });
for ( const c of msg . tool_calls || []) { let input = {}; try { input = c . function ? . arguments ? JSON . parse ( c . function . arguments ) : {}; } catch ( _ ) { input = { __parse_error : String ( c . function ? . arguments || '' ). slice ( 0 , 200 ) }; } blocks . push ({ type : 'tool_use' , id : c . id , name : c . function ? . name , input }); }
return { blocks , usage : { inputTokens : j . usage ? . prompt_tokens ?? null , outputTokens : j . usage ? . completion_tokens ?? null }, stopReason : j . choices [ 0 ]. finish_reason || null , model : j . model || conn . model };
}
2026-10-29 01:11:32 +00:00
// ---------------------------------------------------------------- 로컬 CLI(로그인 세션): claude(Claude Code) · codex(OpenAI Codex CLI)
/**
* [0.3.1] 자동 탐색. HAL 서비스(systemd)의 PATH 에는 사용자 설치 위치(~/.local/bin 등)가 없어 0.3.0 은 bear 에 설치된 CLI 를 찾지 못했다.
* 탐색 순서: 환경 변수(CLAUDE_BIN·CODEX_BIN) → HAL_CLI_DIRS(있으면 이것만) → HOME 의 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin, ~/.claude/local, ~/.codex/packages/standalone/current/bin, ~/node_modules/.bin)
* → /usr/local/bin·/usr/bin → PATH. HOME 은 HAL 실행 계정(HAL_CLI_HOME 으로 바꿀 수 있다 — 테스트용). 심볼릭 링크(~/.local/bin/claude → ~/.local/share/claude/versions/…)도 실행 가능하면 인정.
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
*/
export const CLI_TOOLS = {
claude : { label : 'Claude Code CLI' , defaultModel : 'claude-opus-5-5' , loginCommand : 'claude login' , envBin : 'CLAUDE_BIN' },
codex : { label : 'OpenAI Codex CLI' , defaultModel : 'gpt-5.5' , loginCommand : 'codex login' , envBin : 'CODEX_BIN' },
};
const cliHome = () => process . env . HAL_CLI_HOME || homedir ();
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
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건 통과
2026-10-29 01:43:02 +00:00
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 ; };
2026-10-29 01:11:32 +00:00
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 ;
return [... new Set ([ join ( h , '.local' , 'bin' ), join ( h , '.npm-global' , 'bin' ), join ( h , '.claude' , 'local' ), join ( h , '.codex' , 'packages' , 'standalone' , 'current' , 'bin' ), join ( h , 'node_modules' , '.bin' ), '/usr/local/bin' , '/usr/bin' , ...( process . env . PATH || '' ). split ( ':' ). filter ( Boolean )])];
}
export function findCliBin ( tool ) {
const t = CLI_TOOLS [ tool ]; if ( ! t ) return null ; const env = process . env [ t . envBin ]; if ( env && isExec ( env )) return env ;
for ( const d of cliSearchDirs ()) { const p = join ( d , tool ); if ( isExec ( p )) return p ; } return null ;
}
const runCli = ( bin , args , timeout = 20000 ) => new Promise (( resolve ) => execFile ( bin , args , { env : cliEnv (), timeout , maxBuffer : 4 * 1024 * 1024 }, ( err , stdout , stderr ) => resolve ({ err , stdout : String ( stdout || '' ), stderr : String ( stderr || '' ) })));
/** 로그인 상태. loggedIn: true/false/null(알 수 없음 — 막지 않는다) */
export async function cliLoginStatus ( tool , bin ) {
if ( tool === 'claude' ) { const r = await runCli ( bin , [ 'auth' , 'status' ]); try { const j = JSON . parse ( r . stdout . trim ()); return { loggedIn : 'loggedIn' in j ? !! j . loggedIn : null , method : j . authMethod || null }; } catch ( _ ) { return { loggedIn : null , method : null }; } }
if ( tool === 'codex' ) { const r = await runCli ( bin , [ 'login' , 'status' ]); const t = r . stdout + '\n' + r . stderr ; if ( /not logged in/i . test ( t )) return { loggedIn : false , method : null }; if ( /logged in/i . test ( t )) return { loggedIn : true , method : /chatgpt/i . test ( t ) ? 'chatgpt' : /api key/i . test ( t ) ? 'api_key' : null }; return { loggedIn : null , method : null }; }
return { loggedIn : null , method : null };
}
/** codex 가 로그인 상태로 실행될 때 백엔드 모델 목록을 ~/.codex/models_cache.json 에 둔다(Webmaker engine-codex 와 같은 해석). 숨김(visibility=hide)은 뺀다. */
export function codexModels () { try { const j = JSON . parse ( readFileSync ( join ( cliHome (), '.codex' , 'models_cache.json' ), 'utf8' )); const ms = Array . isArray ( j ) ? j : ( j . models || []); return ms . filter (( m ) => m && m . slug && m . visibility !== 'hide' ). map (( m ) => m . slug ); } catch ( _ ) { return []; } }
export function codexDefaultModel () { try { const m = readFileSync ( join ( cliHome (), '.codex' , 'config.toml' ), 'utf8' ). match ( /^model\s*=\s*"([^"]+)"/m ); return m ? m [ 1 ] : null ; } catch ( _ ) { return null ; } }
export function cliDefaultModel ( tool ) { if ( tool === 'codex' ) return codexDefaultModel () || codexModels ()[ 0 ] || CLI_TOOLS . codex . defaultModel ; return CLI_TOOLS . claude . defaultModel ; }
/** 연결 설정의 CLI 종류: cli.tool → 실행 파일 이름 → claude */
export function cliToolOf ( conn ) { const c = conn . cli || {}; if ( c . tool && CLI_TOOLS [ c . tool ]) return c . tool ; if ( c . bin ) { const b = basename ( c . bin ); if ( CLI_TOOLS [ b ]) return b ; } return 'claude' ; }
export const cliBin = ( conn ) => ( conn . cli && conn . cli . bin ) || findCliBin ( cliToolOf ( conn )) || null ;
/** 서버에서 찾은 CLI 전부(버전·로그인·기본 모델·모델 목록). AI 연동 화면과 /ops/api/ai/cli 가 쓴다. */
export async function discoverClis () {
const clis = [];
for ( const tool of Object . keys ( CLI_TOOLS )) {
const bin = findCliBin ( tool ); if ( ! bin ) continue ; const t = CLI_TOOLS [ tool ];
const v = await runCli ( bin , [ '--version' ], 15000 ); const login = await cliLoginStatus ( tool , bin ); let realpath = bin ; try { realpath = realpathSync ( bin ); } catch ( _ ) {}
clis . push ({ tool , label : t . label , bin , realpath , version : v . err ? null : v . stdout . trim (). split ( '\n' )[ 0 ], versionError : v . err ? ( v . err . code || v . err . message ) : null , loggedIn : login . loggedIn , loginMethod : login . method , defaultModel : cliDefaultModel ( tool ), models : tool === 'codex' ? codexModels () : [], loginHint : login . loggedIn === false ? `서버(HAL 실행 계정)에서 ' ${ t . loginCommand } ' 을 실행해 로그인하세요 — HAL 이 대신 로그인할 수 없습니다` : null });
}
return { clis , searched : cliSearchDirs (), home : cliHome () };
}
export async function cliInfo ( conn ) {
const tool = cliToolOf ( conn ); const t = CLI_TOOLS [ tool ]; const bin = cliBin ( conn );
if ( ! bin || ! isExec ( bin )) throw new ProviderError ( 'cli_unavailable' , ` ${ t . label } 실행 파일을 찾지 못했습니다(탐색 위치: ${ cliSearchDirs (). slice ( 0 , 7 ). join ( ', ' ) } ). 서버에 설치하거나 연결 설정에 절대 경로를 적으세요.` );
const v = await runCli ( bin , [ '--version' ], 15000 ); if ( v . err ) throw new ProviderError ( 'cli_unavailable' , `CLI 실행 파일을 실행할 수 없습니다( ${ bin } ): ${ v . err . code || v . err . message } ` );
const login = await cliLoginStatus ( tool , bin );
return { tool , label : t . label , bin , version : v . stdout . trim (). split ( '\n' )[ 0 ], loggedIn : login . loggedIn , loginMethod : login . method , models : tool === 'codex' ? codexModels () : [], defaultModel : cliDefaultModel ( tool ), loginCommand : t . loginCommand };
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
}
/** 대화 기록을 CLI 프롬프트 글로 편다(도구 없음). */
function transcriptText ( messages ) {
return messages . map (( m ) => m . content . filter (( b ) => b . type === 'text' ). map (( b ) => ` ${ m . role === 'assistant' ? '[나]' : '[운영자/참여자]' } ${ b . text } ` ). join ( '\n' )). filter ( Boolean ). join ( '\n\n' );
}
2026-10-29 01:11:32 +00:00
/** 자식 프로세스 실행 공통: 프로세스 그룹으로 띄워 시간 초과 때 통째로 끝낸다. */
function spawnCollect ( bin , args , { cwd , timeoutMs }) {
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
return new Promise (( resolve , reject ) => {
2026-10-29 01:11:32 +00:00
let child ; try { child = spawn ( bin , args , { cwd , env : cliEnv (), stdio : [ 'ignore' , 'pipe' , 'pipe' ], detached : true }); } catch ( e ) { return reject ( new ProviderError ( 'cli_unavailable' , `CLI 실행 실패: ${ e . message } ` )); }
let out = '' ; let err = '' ; const timer = setTimeout (() => { try { process . kill ( - child . pid , 'SIGTERM' ); } catch ( _ ) {} }, timeoutMs );
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
child . stdout . on ( 'data' , ( c ) => { out += c . toString (); if ( out . length > 4 * 1024 * 1024 ) out = out . slice ( - 4 * 1024 * 1024 ); }); child . stderr . on ( 'data' , ( c ) => { err += c . toString (); if ( err . length > 20000 ) err = err . slice ( - 20000 ); });
child . on ( 'error' , ( e ) => { clearTimeout ( timer ); reject ( new ProviderError ( 'cli_unavailable' , `CLI 실행 파일을 실행할 수 없습니다( ${ bin } ): ${ e . code || e . message } ` )); });
2026-10-29 01:11:32 +00:00
child . on ( 'close' , ( code ) => { clearTimeout ( timer ); resolve ({ code , out , err }); });
HAL AI 채팅룸 도입(0.3.0): 운영자 콘솔 오른쪽 패널에 통합 방(첫 탭 고정) + 모델별 개별 방, 모델 연결(로그인·OAuth PKCE·API 키, 비밀은 Vault), 모델별 고유 정보(이름·역할·특성), HAL 제어·감시 스킬 문서·도구 29종(읽기 즉시 / 쓰기·고위험은 제안 → 운영자 승인(재인증·3단계 확인)), 통합 방 규칙(@이름 지정, 모델→모델 자동 1회 전달), MCP stdio 서버(읽기 전용), 개발 지시 v0.3.0(Oz), docs/HAL-AI.md, VERSION_HISTORY 0.3.0 — test/hal-ai.test.mjs RED 0/11 → GREEN 11/11, 전체 26파일 182건 통과
2026-10-28 17:41:50 +00:00
});
}
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건 통과
2026-10-29 01:43:02 +00:00
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 로 확인).' };
2026-10-29 01:11:32 +00:00
}
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건 통과
2026-10-29 01:43:02 +00:00
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 }) {
2026-10-29 01:11:32 +00:00
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 ) : [])];
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건 통과
2026-10-29 01:43:02 +00:00
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 ;
2026-10-29 01:11:32 +00:00
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 결과를 내지 않았습니다' );
if ( j . is_error ) { const msg = String ( j . result || '오류' ); throw new ProviderError ( /authenticate|login|OAuth/i . test ( msg ) ? 'cli_not_logged_in' : 'cli_failed' , `Claude Code CLI 오류: ${ msg }${ / authenticate | login | OAuth / i . test ( msg ) ? ` — 서버에서 ' ${ CLI_TOOLS . claude . loginCommand } ' 로 다시 로그인하세요` : '' } ` ); }
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 가 사용량. */
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건 통과
2026-10-29 01:43:02 +00:00
async function chatCodex ( d , conn , bin , { system , messages , mcp }) {
2026-10-29 01:11:32 +00:00
const prompt = ` ${ system } \n\n---\n ${ transcriptText ( messages ) } \n\n위 대화의 마지막 발언에 답하라.` ; const dir = mkdtempSync ( join ( tmpdir (), 'hal-codex-' ));
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건 통과
2026-10-29 01:43:02 +00:00
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 도구 제공
2026-10-29 01:11:32 +00:00
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 ); }
if ( ! text ) { const tail = errText || res . err . trim (). split ( '\n' ). pop () || `종료 코드 ${ res . code } ` ; throw new ProviderError ( /login|auth|unauthorized|401/i . test ( tail ) ? 'cli_not_logged_in' : 'cli_failed' , `Codex CLI 오류: ${ tail }${ / login | auth | unauthorized | 401 / i . test ( tail ) ? ` — 서버에서 ' ${ CLI_TOOLS . codex . loginCommand } ' 을 실행하세요` : '' } ` ); }
return { blocks : [{ type : 'text' , text }], usage , stopReason : 'end_turn' , model : conn . model };
}