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' ;
2026-10-29 01:11:32 +00:00
import { accessSync , constants , statSync , realpathSync , readFileSync , mkdtempSync , rmSync } from 'node:fs' ;
import { join , basename } from 'node:path' ;
import { homedir , tmpdir } from 'node:os' ;
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 ; } }
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 },
2026-10-29 01:11:32 +00:00
'local-cli' : { label : '로컬 CLI 로그인 세션(Claude Code·Codex)' , authKinds : [ 'login' ], defaultBase : null , tools : false },
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 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 } ` };
}
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 );
const models = (( 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 });
throw new ProviderError ( 'bad_provider' , `알 수 없는 공급자: ${ conn . provider } ` , { status : 400 });
}
// ---------------------------------------------------------------- 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 등)을 찾게 한다. */
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 ;
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
});
}
2026-10-29 01:11:32 +00:00
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 });
}
async function chatClaude ( conn , bin , { system , messages }) {
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 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 가 사용량. */
async function chatCodex ( conn , bin , { system , messages }) {
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 ) : [])];
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 };
}