다중 진입과 Typert: 커널 하나, 얼굴 다섯
Web, headless, ACP, SDK, HTTP가 같은 커널을 공유해요.
먼저 해보고 말할게요. 왼쪽이 진입, 오른쪽이 커널이에요. 탭 다섯 개가 얼굴 다섯 장—하나 고르고 「재생」을 누르세요. 그 진입이 어떤 껍질을 쓰고 어떤 전문을 내는지, 커널 세션 로그에 어떤 이벤트가 떨어지는지 보세요. 진입을 바꿔 다시 재생하고 오른쪽 이벤트 줄을 지켜보세요. 이 레슨이 말할 전부예요.
examples/headless-agent/cordis.yml, examples/jsonrpc-agent/cordis.yml, examples/acp-agent/cordis.yml, docs/api-gateway.zh.md를 기준으로 정리했고, 공유 커널 플러그인은 세 설정지의 교집합이에요.앞 레슨에서 말했듯 DSH의 모든 기능은 Cordis 플러그인이고, 프로세스 기동 때 cordis.yml 한 장으로 플러그인을 트리에 겁니다. 그 설계가 여기서 빛을 봐요. 이른바 「진입」은 다른 cordis.yml 한 장일 뿐이에요. 저장소 examples/ 아래 headless, JSON-RPC, ACP 세 장이 있어요. 펼쳐 보면 거의 비슷해요. DeepSeek 어댑터, bash 실행기, JSONL 세션 영속화, 압축, 파일시스템 도구—커널 플러그인은 세 장 모두 있고, 차이는 맨 위 몇 줄에 몰려 있어요. JSON-RPC는 sdk-jsonrpc-server를 더 걸고, ACP는 acp-demo 프로토콜 브리지와 샌드박스 정책을 더 걸고, headless는 서버를 아예 안 걸어요. 프로세스 자체가 진입이에요.
Web 얼굴의 껍질은 조금 두껍지만 역시 플러그인이에요. host-webserver는 순수한 node:http 캐리어로, 문서에 agent loop에 속하지 않고 harness 개념을 모른다고 명시돼 있어요(docs/subsystems/web-server.zh.md). frontend-static은 폴백 자리를 맡아 SPA 서버가 되고, client-modules는 tapIndex로 index.html에 부트 목록 window.__DSH_BOOT__를 주입해 브라우저가 플러그인 프론트 모듈을 그대로 로드해요. HTTP API 얼굴은 체인이에요. api-remotes가 신원 해석, api-gateway가 인자 디코드와 메서드 호출, connection이 /api 라우트의 RPC 봉투를 독점한 뒤 같은 webserver로 돌아가요(docs/api-gateway.zh.md).
Python SDK가 「껍질」이 얼마나 얇은지 가장 잘 보여줘요. pip install deepseek-harness-sdk는 플랫폼 wheel도 같이 깔고, 그 안에 단일 파일 실행 파일 dsh-jsonrpc-agent가 있어요. SDK가 이를 서브프로세스로 띄우고 DSH_CORDIS_CONFIG로 기본 조합을 주입한 뒤 stdio로 JSON-RPC를 말해요(python/sdk/README.zh.md). 그래서 Python SDK와 JSON-RPC 진입은 같은 얼굴의 두 가지 입는 법이고, Python 층은 프로토콜을 harness.run("…")로 감싼 것뿐이에요.
강의 개요의 경계 조건 문제 답은 설정 주석에 있어요. JSON-RPC 예시 2행은 stdout을 JSON-RPC에 남겨 두고 console logger나 터미널 UI를 붙이지 말라고 하고, ACP 예시도 트리 전체에 stdout 로그와 HMR을 걸지 않는다고 선언해요. stdout이 ACP의 JSON-RPC를 나르기 때문이죠(두 cordis.yml 머리 주석). 한 줄로: 두 프로토콜은 stdout을 전송선으로 쓰고, 그 위에 로그 한 줄을 섞으면 상대 파서가 끊겨요. 로그는 ctx.logger로 다른 길을 갑니다. 이게 프로토콜 진입의 철칙이에요.
진입 = cordis.yml 한 장세 예시 진입이 같은 커널 플러그인 묶음을 공유하고, 차이는 맨 위 프로토콜 브리지 몇 줄이에요. 새 얼굴 하나 ≈ 번역 플러그인 하나 + 설정 한 장.
stdout은 프로토콜 것JSON-RPC와 ACP 진입 설정은 console logger를 명시적으로 금지해요. stdout은 전송선이라 로그 한 줄을 섞으면 상대가 파싱에 실패합니다.
표시 없는 메서드는 존재하지 않음Typert는 @Remote로 표시된 메서드만 내보내고, 표시 없는 것은 Client 타입에도 안 들어가며 ctx.remote로도 호출할 수 없어요.
examples/jsonrpc-agent/cordis.yml을 펼치면 1행 주석은 번들 런타임용 무인 배포라고 하고, 2행이 그 철칙의 원문이에요. stdout은 JSON-RPC에 남겨 두고 console logger나 터미널 UI를 붙이지 말 것. 아래로 가면 프로토콜 브리지 sdk-jsonrpc-server는 플러그인 목록의 평범한 첫 항목일 뿐이고, 설정조차 환경 변수 주입으로 와요. 진입의 전부 살림이 이거예요. 설정 한 장, 맨 위 몇 줄이 얼굴이고 나머지는 다른 진입과 공유하는 커널.
출처: examples/jsonrpc-agent/cordis.yml 1–7행, 확인일 2026-08-13.
다섯 얼굴 중 Web과 HTTP API는 Host의 비즈니스 메서드를 프로세스 너머로 호출해야 해서 RPC 층이 필요해요. DSH는 기성 프레임워크 대신 Typert를 자작했어요. 비즈니스 개발자가 할 일은 데코레이터 하나뿐이에요. 서비스 메서드에 @Remote('create')를 달면, 빌드 때 Typert가 TypeScript 타입 그래프를 분석해 세 가지를 만듭니다. 인자 검증 Zod schema, 호출을 설명하는 descriptor, 브라우저용 타입 선언. 라우트표·인자 변환·클라이언트 stub을 손으로 쓸 필요 없고, 메서드 시그니처 한 곳을 고친 뒤 다시 빌드하면 모든 단의 호출 계약이 같이 갱신돼요(docs/subsystems/typert.zh.md, packages/typert/generator/README.zh.md).
왜 기성 방안이 안 되나요? wire를 넘는 것 안에 Cordis 고유 개념이 있고, 일반 schema 생성기에는 그걸 말할 어휘가 없어요. 예 세 가지. 첫째, Host와 브라우저는 독립 TypeScript Program 둘이라 같은 이름 Cordis Context의 타입 병합 결과가 달라, schema 한 장으로 양쪽을 먹일 수 없어요. 둘째, 비즈니스 메서드 인자가 Agent 같은 살아있는 객체일 수 있어 wire로 직렬화할 수 없고, Typert lookup이 agent 인자를 wire 필드 agentId로 바꾼 뒤 Gateway가 id를 다시 살아 있는 객체로 풀어 메서드를 호출해요. 셋째, 클라이언트 ctx.remote.goals는 플러그인 마운트·언마운트에 따라 사는 서비스라, 마지막 메서드가 철회되면 네임스페이스 전체가 같이 내려가요. OpenAPI 같은 정적 기술로는 그 생명주기를 못 담아요(Typert Gateway Agent Note 2026-08-02).
기억할 규율 하나 더. descriptor는 로컬 리플렉션 정보라 wire에 안 올라가요. Host와 클라이언트가 빌드 때 서로 대응하는 descriptor를 각각 만들고, 요청에는 endpoint와 이름 있는 인자만 보냅니다. 취소 시그널은 대역 외 carrier signal로 주입하고 비즈니스 인자에 절대 섞지 않아요(docs/subsystems/typert.zh.md의 호출 descriptor 절). 생성기는 고집스러워요. 표현할 수 없는 타입 프로젝션은 바로 에러를 내고, 원본 타입을 납작하게 약화시켜 넘기지 않습니다. 앞 두 레슨의 「해석 거부」와 한 줄기예요. 분명히 말할 수 없으면 하지 않아요.
Goal 서비스의 실제 코드예요. 데코레이터 하나와 얇은 어댑터—JSDoc의 “wire identity에서 해석한 살아 있는 Agent”가 비즈니스 쪽에서 본 lookup의 모습이에요.
/**
* Create one Goal through the remote boundary.
* @param agent - exact live Agent resolved from the wire identity.
* @param request - objective and optional round cap.
* @returns the created Goal identity.
*/
@Remote('create')
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
const view = this.create(agent, request)
return { ref: { id: view.id, revision: view.revision } }
}
deepseek-harness-master 기준, 확인 파일 packages/goal/goal/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.Grok Build
두 제품이 ACP 얼굴에서 정면으로 만나요. Grok Build는 독립 Rust 구현 xai-acp-lib(crates/codegen/xai-acp-lib/)가 있어요. stdin 줄 리더, 양방향 채널, 게이트웨이 송수신기—역시 stdio JSON-RPC라 「stdout은 프로토콜 것」 규율을 똑같이 지켜야 해요. 에디터가 코딩 agent에 붙는 길에서 ACP는 사실상 표준이 되고 있어요.
차이는 얼굴 수와 자라는 방식이에요. Grok Build는 데스크톱·CLI가 본체이고 ACP는 에디터용 인터페이스; DSH는 다섯 얼굴을 전부 설정 차이로 펼쳐 놓고 커널은 진입을 모릅니다. 사이트 안 Grok 특집이 전체 아키텍처를 분해해 두었으니 나란히 보세요.
Claude Code
길은 반대예요. TUI-first. 터미널 CLI가 본체이고, headless는 같은 실행 파일의 -p 모드, Agent SDK가 그 바깥을 한 겹 더 감싸요. 여러 얼굴이 같은 CLI에서 갈라져 나와요. 프로세스 하나·UI 하나라 RPC 층이 필요 없고, Typert가 풀 문제도 없어요.
DSH는 Web-first예요. 출시 때 전통 터미널 인터랙티브 진입조차 없었고, 출시 토론에서 가장 큰 목소리는 “내 CLI는?”이었어요. 진입 취사의 제품 결정이에요. 먼저 커널과 얼굴을 분리한 아키텍처를 세우고, 없는 껍질을 채우면 됩니다. 두 길에 정답은 없고 비용 구조가 달라요. TUI-first에 Web 얼굴을 더하면 RPC 층 전체가 필요하고, Web-first에 CLI 얼굴을 더하면 이론상 새 cordis.yml 한 장과 드라이버 하나면 돼요.
여섯 번째 얼굴 설계하기
DSH에 채팅 앱 봇 진입을 넣는다고 가정해요. 사용자가 그룹에서 @봇에게 말하고, 답장이 그룹으로 흘러가요. 이 레슨 방식으로 목록을 쓰세요. 안 써도 되는 것?(커널 플러그인, 세션 영속화, 압축, 도구—기존 cordis.yml을 그대로 베끼면 돼요.) 반드시 써야 하는 것?(그룹 메시지를 세션 prompt로, 세션 이벤트를 그룹 답장으로 바꾸는 프로토콜 브리지 플러그인 하나.) 디테일 하나 더: 이 브리지에 stdout 상호배제 문제가 있나요? 없다면 「전송선 규율」의 등가물은 무엇일까요?(힌트: 그룹 메시지는 속도 제한과 길이 제한이 있어 이벤트 스트림을 스로틀·병합해야 해요.)
cordis.yml 한 장과 프로토콜 번역 플러그인일 뿐이고, 같은 작업이 어느 얼굴로 들어오든 세션 로그에 떨어지는 이벤트는 한 글자도 다르지 않아요. 프로토콜 진입은 「stdout은 프로토콜 것」 철칙을 지키며 로그를 전송선에 절대 섞지 않아요. wire 너머 호출은 자작 Typert에 맡깁니다. @Remote 데코레이터 하나, 타입 그래프가 schema·descriptor·클라이언트 타입을 만들고, 살아 있는 객체는 lookup으로 wire id가 되며, 시그니처 한 곳을 고치면 전 단이 동기화돼요.