HAL 0.3.9: 로그인 터미널·텍스트 셸·모델 선택·슬래시 명령·점검 수정 — claude 실제 로그인 명령 auth login(대화형 세션·신뢰 설정도 허용, 작업 폴더 HOME), 텍스트 셸 범용화(순수 단말 에뮬레이터 term.mjs 로 TUI 렌더, 원시 키 전달·키 단추·붙여넣기·단말 크기), 유령 세션 한도 오류 수정(닫기=종료, 살아 있는 프로세스만 계산, 한도 3, 모두 종료·세션 목록), 모델 선택(확인된 목록·PATCH·/model), 슬래시 명령 20개(/help /status /order /deploy …, 원문 기록·확장 지시), 패널 로그인·재인증 단추, 개발 지시 v0.3.9(Oz)·docs/HAL-AI.md §8·VERSION_HISTORY 0.3.9 — test/hal-ai-039.test.mjs RED 0/6 → GREEN 6/6, 전체 28파일 195건 통과

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5.1
2026-10-29 02:22:21 +00:00
parent f861c7ee5a
commit fe4800f94a
20 changed files with 506 additions and 49 deletions
+20 -2
View File
@@ -20,6 +20,7 @@ import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.mjs';
import { toolFormat } from './providers.mjs';
import { parseCommand, helpText } from './commands.mjs';
import * as P from './providers.mjs';
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
@@ -54,7 +55,7 @@ export function createEngine(d) {
const out = [];
for (const m of rows) {
if (m.status === 'failed') continue;
if (m.role === 'user') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; }
if (m.role === 'user') { const cmd = m.content.find((b) => b.type === 'command'); const t = cmd && cmd.expanded ? cmd.expanded : textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; } // [0.3.9] 슬래시 명령은 확장된 지시를 모델에게
if (m.role === 'system') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: `[HAL] ${t}` }] }); continue; }
if (m.role === 'tool') { if (m.connectionId === conn.id) out.push({ role: 'user', content: m.content.filter((b) => b.type === 'tool_result') }); continue; }
if (m.role === 'assistant') {
@@ -138,13 +139,30 @@ export function createEngine(d) {
send(roomId, { text, operator, context = null }) {
const t = String(text || '').trim(); if (!t) throw new HttpError(400, 'empty_message', '지시 내용이 비어 있습니다'); if (t.length > MAX_TEXT) throw new HttpError(400, 'message_too_long', `지시는 ${MAX_TEXT}자까지입니다`);
const room = R.getRoom(d, roomId); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다');
// [0.3.9] 슬래시 명령: local 은 바로 처리(모델 호출 없음), prompt 는 확장 지시로 바꿔 보낸다
let command = null; if (t.startsWith('/')) {
const pc = parseCommand(t); if (!pc || !pc.command) throw new HttpError(400, 'unknown_command', `모르는 명령입니다: ${t.split(/\s+/)[0].slice(0, 30)} — /help 로 명령 목록을 보세요`);
if (pc.command.requiresArgs && !pc.args) throw new HttpError(400, 'command_args_required', `인자가 필요합니다: ${pc.command.usage}`);
if (pc.command.kind === 'local') return engine.localCommand(room, pc, operator);
command = { type: 'command', name: pc.name, args: pc.args, expanded: pc.command.expand(pc.args) };
}
let responders;
if (room.kind === 'direct') { const conn = C.get(d, room.connection_id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (conn.status !== 'active') throw new HttpError(409, 'connection_inactive', `연결 "${conn.name}" 이(가) ${conn.status} 상태라 보낼 수 없습니다`, { status: conn.status, lastError: conn.lastError }); responders = [conn]; }
else { const participants = C.active(d); if (!participants.length) throw new HttpError(409, 'no_active_connections', '활성 AI 연결이 없습니다 — AI 연동 화면에서 연결을 추가하세요'); const { addressed, unknown } = R.parseAddressing(t, participants); if (unknown.length) throw new HttpError(400, 'unknown_addressee', `모르는 이름입니다: ${unknown.map((n) => '@' + n).join(', ')}`, { unknown, participants: participants.map((p) => p.name) }); responders = addressed.length ? participants.filter((p) => addressed.includes(p.id)) : participants; }
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: command ? [{ type: 'text', text: t }, command] : [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const turns = responders.map((conn) => startTurn(room, conn, { relayDepth: 0, operator, context }));
return { message, responders: responders.map((c) => c.id), turns };
},
/** [0.3.9] HAL 이 바로 처리하는 명령(/help·/tools·/model·/reset). 모델을 부르지 않는다. */
localCommand(room, pc, operator) {
const note = (text) => ({ message: R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text }], relayDepth: 0 }), responders: [], turns: [], command: pc.name });
if (pc.name === 'help') return note(helpText());
if (pc.name === 'reset') { R.resetRoom(d, room.id); return { message: null, responders: [], turns: [], command: 'reset' }; }
const conn = room.kind === 'direct' ? C.get(d, room.connection_id) : null;
if (pc.name === 'tools') { const list = conn ? [conn] : C.active(d); return note(list.length ? list.map((c) => `${c.name}: 도구 정책 ${c.tools.preset}(${c.tools.enabled.length}개)${c.toolsVia === 'mcp' ? ' · MCP' : ''}\n ${c.tools.enabled.join(', ') || '(없음)'}`).join('\n') : '활성 연결이 없습니다'); }
if (pc.name === 'model') { if (!conn) throw new HttpError(400, 'command_needs_direct_room', '/model 은 개별 방(모델 탭)에서만 쓸 수 있습니다'); const before = conn.model; const c2 = C.update(d, conn.id, { model: pc.args }, operator ? operator.id : null); return note(`모델을 ${before} → ${c2.model} 로 바꿨습니다(연결 ${c2.name}).`); }
throw new HttpError(400, 'unknown_command', '모르는 명령입니다 — /help');
},
listActions({ roomId = null, status = null, limit = 200 } = {}) { const w = []; const p = []; if (roomId) { w.push('room_id=?'); p.push(roomId); } if (status) { w.push('status=?'); p.push(status); } return d.store.all(`SELECT * FROM ai_actions ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY proposed_at DESC LIMIT ?`, ...p, Math.min(1000, limit)).map(publicAction); },
getAction(id) { return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id)); },
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */