아키텍처
폴더 구조가 곧 네임스페이스
섹션 제목: “폴더 구조가 곧 네임스페이스”클래스 이름은 그 파일이 어디 있는지 알려줍니다. ActiveCli.Adapter.Claude.ClaudeAdapter는 src/ActiveCli/Adapter/Claude/ClaudeAdapter.ts이며, 파일 하나에 클래스 하나입니다.
ActiveCli/ Command/ Command — 공개 표면: 쌓고, 조회하고, 보낸다 Provider/ 호출자가 이름 부를 수 있는 정체성으로서의 CLI; Claude, 그리고 ask() Auth/ claude auth: 상태, 그리고 살아 있는 로그인 흐름 Mcp/ claude mcp: list, get, add, remove, 그리고 출력 파서 Adapter/ AbstractAdapter와 CLI마다 하나씩의 하위 클래스 Claude/ claude CLI: argv, 권한 플래그, auth env, 어댑터 Event/ CLI가 우리에게 말한 것을, 인스턴스로 Message/ 우리가 CLI에게 말하는 것: 턴, 첨부, 컨트롤 요청 Path/ 자신의 종류를 아는 경로 Process/ Exec/ 짧은 명령들. 어떤 셸도 재작성하지 못하도록 인용된 Job/ Windows Job Object. 떨어져 나가는 자손을 위해 Launcher/ 실제로 실행될 실행 파일을 찾는 일 Path/ GUI 프로세스가 가졌어야 할 PATH Tree/ CLI가 spawn한 모든 것을 죽이는 일 Watchdog/ 호스트가 죽었음을 알아채는 일 Registry/ 살아 있는 CLI의 디스크 기록. 크래시가 남긴 고아를 위해 Session/ 살아 있는 대화 Stream/ 파이프 청크에서 JSON 줄을 재조립하는 일src/index.ts가 유일한 공개 표면입니다. 트리 내부에는 barrel 파일이 없으며, 모든 내부 import는 원하는 파일을 직접 지목합니다.
호출자가 쥐는 것, 그리고 그 아래에 있는 것
섹션 제목: “호출자가 쥐는 것, 그리고 그 아래에 있는 것”이 라이브러리에는 하나의 공개 진입점과 그 아래의 여러 계층이 있습니다. 소비자는 맨 윗줄에만 닿습니다. 그 아래 모든 것은 닿을 수 있지만 API가 겨냥하는 자리는 아닙니다.
| 계층 | 클래스 | 호출자가 닿는 정도 |
|---|---|---|
| 공개 표면 | Command, AbstractProvider / Claude, Answer |
언제나 |
| 일회성 명령 | AuthCommands, McpCommands, LoginSession |
command.auth / command.mcp를 통해 |
| 대화 | Session, SpawnOptions, UserMessage, ControlRequest |
조회할 때, 또는 어댑터를 작성할 때 |
| 프로바이더 번역 | AbstractAdapter, ClaudeAdapter, ClaudeArgv, ClaudePermissionFlag, ClaudeAuthEnv |
어댑터를 작성할 때만 |
| 운영체제 | Process/*, Path/*, Registry/*, Stream/* |
드물게 — 그리고 어댑터 안에서는 절대로 |
Command가 자기만의 계층으로 존재하는 이유
섹션 제목: “Command가 자기만의 계층으로 존재하는 이유”Session은 살아 있는 프로세스입니다. 하나를 생성하면 spawn됩니다. 그것 자체로는 올바른 모양이지만, 마음을 바꾸고 싶은 호출자에게는 잘못된 모양입니다.
| 질문 | Session만으로 |
Command와 함께 |
|---|---|---|
| 보내기 전에 model 변경 | spawn한 뒤 컨트롤 요청 — 또는 재spawn | setModel(); 다음 send()에 적용 |
| permission 모드 변경 | 호출자가 그것이 spawn 시점 전용임을 알고 재spawn해야 함 | setPermissions(); 필요하면 send()가 재spawn |
| CLI 전환 | 다른 어댑터와 새 세션을 손으로 만듦 | command.provider = Codex |
| 커맨드라인 확인 | adapter.buildArgv(options) — 옵션을 먼저 조립해야 함 |
command.toArgv() |
각 행은 이전 표면이 새어 나가게 했던 세부사항입니다. Command는 프로세스가 아니라 의도를 쥠으로써 그것들을 흡수하며, 이는 그것을 적용할 실행 지점이 단 하나이기에 가능합니다.
라이브러리의 두 절반
섹션 제목: “라이브러리의 두 절반”트리는 프로바이더별인 것과 보편적인 것으로 깔끔하게 나뉩니다.
| 절반 | 네임스페이스 | 자라나는 때 |
|---|---|---|
| 프로바이더별 | Adapter/*, Provider/* |
새 CLI를 지원할 때 |
| 보편 | Process/, Path/, Stream/, Registry/, Session/ |
새로운 운영체제 문제를 발견할 때 |
| 공유 어휘 | Command/, Event/, Message/, Session/PermissionMode |
어떤 개념이 둘 이상의 CLI에 공통임이 드러날 때 |
| CLI 서브커맨드 | Auth/, Mcp/ |
CLI 서브커맨드를 감쌀 가치가 생길 때 — 현재는 Claude 형태이며, 두 번째 프로바이더가 압력을 가할 첫 지점 |
이것이 어댑터 계약이 강제하는 구분입니다. 하위 클래스는 자기 CLI가 다르게 쓰는 모든 것을 소유합니다 — argv, 권한 플래그의 어휘, 출력이 분류되는 방식. 실행 파일을 찾거나, PATH를 보강하거나, 프로세스 트리를 죽이는 일은 소유하지 않습니다. 그것들은 모든 CLI에 대해 같은 문제이기 때문입니다.
| 계층 | 역할 | 주요 클래스 |
|---|---|---|
| 호스트 대면 | 소비자가 쥐고 호출하는 것 | Command, AbstractProvider, Claude, Answer, CliRegistry |
| 서브커맨드 | 답이 있는 일회성 CLI 질문 | AuthCommands, AuthStatus, LoginSession, McpCommands, McpOutputParser |
| 대화 | 살아 있는 프로세스와 그 재조립 상태 | Session, SpawnOptions, UserMessage |
| 프로바이더 | 의도를 한 CLI의 철자로 번역 | AbstractAdapter, ClaudeAdapter, ClaudeArgv, ClaudePermissionFlag, ClaudeAuthEnv |
| 프로토콜 | 양방향의 어휘 | AbstractEvent 하위 클래스, EventParser, ControlRequest, 첨부 |
| 전송 | 파이프 청크를 줄로 | NdjsonBuffer |
| 운영체제 | 실행하고, 찾고, 가두고, 죽이는 일 | AbstractLauncher, AugmentedPath, JobSpawner, AbstractProcessTree, ParentWatchdog |
| 값 | 자기 자신임으로써 질문에 답하는 타입 | AbstractPath 하위 클래스, PermissionMode, SessionId |
의존 방향
섹션 제목: “의존 방향”의존은 아래로, 안으로 향합니다. 호스트 대면 클래스가 프로바이더와 OS 클래스에 의존하고, 값 클래스는 아무것에도 의존하지 않습니다.
graph TD Host["Host application"] --> Command Host --> Registry["Registry.CliRegistry"] Command --> Provider["Provider.AbstractProvider"] Command --> Session Command --> Auth["Auth.AuthCommands"] Command --> Mcp["Mcp.McpCommands"] Command --> Options["Adapter.SpawnOptions"] Command --> Message["Message.UserMessage"] Provider --> Adapter["Adapter.AbstractAdapter"] Auth --> Runner["Process.Exec.CommandRunner"] Mcp --> Runner Mcp --> McpParser["Mcp.McpOutputParser"] Session --> Adapter Session --> Stream["Stream.NdjsonBuffer"] Session --> Tree["Process.Tree.AbstractProcessTree"] Session --> Control["Message.ControlRequest"] Adapter --> Event["Event.EventParser"] Adapter --> Argv["Adapter.Claude.ClaudeArgv"] Adapter --> AugPath["Process.Path.AugmentedPath"] Adapter --> Job["Process.Job.JobSpawner"] Argv --> Mode["Session.PermissionMode"] Options --> Path["Path.PathParser"]전반에 걸쳐 네 가지 규칙이 성립합니다.
Command는 언제 spawn할지 결정하는 유일한 클래스입니다. 그 아래의 모든 것은 생성될 때 실행되거나(Session) 호출될 때 실행됩니다(CommandRunner). 그 결정을 한곳에 두는 것이 애초에 지연을 가능하게 합니다.Session은 자신이 어떤 CLI를 모는지 끝내 알지 못합니다.AbstractAdapter를 쥐고spawn과parseLine을 요청할 뿐입니다. 그것이 어떤 어댑터로부터 만들어졌는지는 그 이후로 구현 세부사항입니다.Process/는 어댑터도, 이벤트도, 세션도 알지 못합니다. 운영체제 문제에 대한 자족적인 해법이며, 통째로 들어내도 됩니다.- 값 클래스는 아무것도 import하지 않습니다.
AbstractPath,PermissionMode,SessionId가 맨 아래에 있습니다.
의도적인 역전 두 가지를 짚어둘 만합니다. PathParser가 AbstractPath의 static 팩토리가 아니라 별도 클래스인 것은 베이스 클래스가 자신의 하위 클래스를 import하지 않아도 되게 하기 위함입니다. 그리고 AbstractProvider.ask()는 Command를 지연 import합니다. Command가 프로바이더 쪽으로 되짚어 오므로 정적 import는 순환이 되기 때문입니다.
플랫폼이 결정되는 곳
섹션 제목: “플랫폼이 결정되는 곳”process.platform에 물어 동작을 고르는 클래스는 셋뿐이며, 각각 그것을 파라미터로 받으므로 테스트에서 답을 고정할 수 있습니다.
| 클래스 | 결정하는 것 |
|---|---|
LauncherFactory |
WindowsLauncher인가 PosixLauncher인가 |
ProcessTreeFactory |
WindowsProcessTree인가 PosixProcessTree인가 |
ClaudeAdapter |
spawn이 Job Object를 거칠지 여부 |
PathParser는 같은 성격의 결정을 호스트 플랫폼이 아니라 경로 자신의 모양으로부터 내립니다. Windows 경로가 Linux 배포판 안에서 도는 프로세스에 건네지는 일은 얼마든지 있으므로, 호스트는 물어볼 대상이 아닙니다.
두 클래스가 자기 내용을 위해 플랫폼 파라미터를 더 받습니다. 후보 디렉토리가 플랫폼마다 다른 BinDirectories, 그리고 win32에서는 PowerShell로, 그 밖에서는 ps로 커맨드라인을 읽는 ProcessIdentity입니다.
커맨드의 일생
섹션 제목: “커맨드의 일생”sequenceDiagram participant H as Host participant C as Command participant S as Session participant A as ClaudeAdapter participant P as CLI process
H->>C: Command.open(Claude, options) Note over C: 아직 아무것도 spawn되지 않음 H->>C: subscribe / onPermission H->>C: setModel / setMessage H->>C: send() C->>C: toSpawnOptions() C->>S: Session.start(provider.createAdapter(), options) S->>A: spawn(options) A->>A: ClaudeArgv.build(...) A->>A: AugmentedPath.toEnv(PROTOCOL_ENV + env + authStrip) A->>P: spawn (detached, win32에서는 JobSpawner를 통해) A-->>S: ChildProcess C->>S: whenStarted() C->>S: ask(UserMessage) S->>P: stdin: JSON 한 줄 P-->>S: stdout: 파이프 청크 S->>S: NdjsonBuffer.push(chunk) S->>A: parseLine(line) A-->>S: AbstractEvent 또는 null S-->>C: onEvent(event) C->>C: responder가 PermissionRequestEvent에 답함 C->>S: send(request.approve()) S->>P: stdin: control_response C-->>H: subscribe 리스너 H->>C: 다시 send() Note over C: provider나 permission 모드가<br>바뀌지 않았다면 같은 프로세스 재사용 H->>C: close() S->>P: 프로세스 트리 종료그 흐름에서 놓치기 쉬운 네 가지 세부사항이 있고, 모두 의도된 것입니다.
- 턴은 spawn을 기다립니다. OS가 프로세스를 실제로 실행하기 전에 stdin에 쓰면 줄이 조용히 유실됩니다 — 파이프는
spawn()이 반환하는 순간부터 존재하지만 그 뒤의 프로세스는 아직 아닐 수 있습니다.Session.send는whenStarted()가 정착할 때까지 쓰기를 미루므로, 호출자 입장에서는 어느 쪽이든 동기적인 전송으로 보입니다. - 권한 responder가 subscriber보다 먼저 실행됩니다. 응답이 호스트의 리스너가 이벤트로 무엇을 하든 그것을 기다려서는 안 됩니다.
- 두 번째
send()는 프로세스를 재사용합니다. provider나 permission 모드가 바뀌지 않았다면 그렇습니다. permission 모드는 spawn 시점 플래그이므로, 그것을 바꾼다는 것은resume: true로 새 프로세스를 띄운다는 뜻입니다. - 첨부는 보내지고 나면 비워집니다. 다음 턴이 그것을 조용히 다시 실어 나르지 않도록.
- close 시 버퍼는 리스너에게 알리기 전에 flush됩니다. 끝에 개행 없이 종료한 CLI는 마지막 이벤트를 버퍼에 남겼고, 그것도 여전히 CLI가 말한 것입니다.
parseLine이null을 반환하는 것은 그 줄이 프로토콜 출력이 아니었다는 뜻입니다 — 같은 스트림 위의 경고입니다. 오류로 보고되는 것이 아니라 건너뛰어집니다.
두 채널, 그리고 둘 다 존재하는 이유
섹션 제목: “두 채널, 그리고 둘 다 존재하는 이유”호스트는 두 가지 방식으로 CLI에 닿으며, 둘은 서로 다른 보장을 갖습니다.
| 채널 | 닿는 대상 | 쓰임 | 보장 |
|---|---|---|---|
spawn 플래그(ClaudeArgv) |
다음 프로세스 | session id, permission 모드, model | 문서화된 CLI 표면 |
컨트롤 채널(ControlRequest) |
살아 있고 응답 가능한 프로세스 | 인터럽트, model 대상 변경 | 최적화로 취급할 것 |
model에 대해 두 경로가 모두 존재하는 이유는 서로 다른 순간을 다루기 때문입니다. SetModel은 이미 실행 중인 것의 대상을 바꾸고, --model은 아무것도 실행되지 않는 동안 내린 선택을 고정해 다음 spawn이 그것을 지키게 합니다. 어느 하나만으로는 충분하지 않습니다.
컨트롤 채널은 의도적으로 의존 대상이 아닙니다. Interrupt에는 언제나 존재하는 폴백 — 프로세스 정지 — 이 있고, 확실성이 필요한 호스트는 그것을 써야 합니다.
고아에 대한 다층 방어
섹션 제목: “고아에 대한 다층 방어”호스트가 사라진 뒤에도 남아 도는 CLI는 계속 할당량을 소모합니다. 여러 장치가 겹치는 이유는 각각이 서로 다른 방식으로 실패하기 때문입니다.
| 장치 | 잡는 것 | 실패하는 때 |
|---|---|---|
PosixProcessTree (그룹 시그널) |
POSIX의 모든 자손 | win32에는 해당 없음 |
WindowsProcessTree (taskkill /T) |
win32의 살아 있는 부모-자식 트리 | 프로세스가 그 트리에서 스스로 떨어져 나간 경우 |
JobSpawner (Job Object) |
PPID와 무관하게, 떨어져 나간 자손 | 래퍼 asset이 없는 경우 |
ParentWatchdog |
정리 코드를 실행하지 않고 죽은 호스트 | 우리 프로세스가 SIGKILL당한 경우 |
CliRegistry |
위의 모든 것이 놓친 것을, 사후에 | state 디렉토리에 쓸 수 없는 경우 |
마지막 행이 레지스트리의 요점입니다. 프로세스 내부의 모든 방어 장치는 우리 코드가 얼마간 실행되어야 하는데, 호스트에 대한 강제 SIGKILL은 그 어느 것도 실행하지 않습니다. spawn 시 파일을 쓰고 종료 시 지우는 것이, 살아남은 것을 나중의 호스트가 찾을 수 있게 만듭니다.
테스트의 형태
섹션 제목: “테스트의 형태”12개 파일에 걸친 132개 테스트를 vitest로 실행합니다. 그 구조는 아키텍처로부터 따라 나옵니다.
| 기법 | 위치 | 가능한 이유 |
|---|---|---|
| 실제 프로세스 | Session.test.ts가 node -e <script>를 스크립트 CLI로 실행 |
어댑터 계약이 테스트 안에서 구현할 만큼 작음 |
| 고정된 플랫폼 | 런처·트리·어댑터 테스트 | 플랫폼이 생성자 파라미터임 |
| 순수 로직 추출 | WindowsLauncher.pick(), JobSpawner.buildCommandLine() |
Windows 호스트 없이도 규칙을 단언할 수 있도록 public으로 만듦 |
| 협력자 대체 | 레지스트리 테스트의 StubIdentity |
전반에 걸친 생성자 기본값 주입 |
| 임시 디렉토리 | CliRegistry 테스트 |
레지스트리 디렉토리는 호출자가 고르는 것 |
| 산문 픽스처 | McpOutputParser.test.ts가 실제 claude mcp list / mcp get 출력을 상대로 단언 |
출력된 텍스트의 파싱은 샘플에 고정해둘 만한 계약임 |
npm test # vitest run — 132개 테스트, 12개 파일npm run typechecknpm run build # tsc, 그다음 copy-assetscopy-assets 단계가 존재하는 이유는 tsc가 자신이 컴파일한 것만 내놓기 때문입니다. 그대로 두면 win-job-wrapper.ps1이 배포 패키지에서 빠집니다. JobSpawner는 래퍼가 없을 때 우아하게 성능 저하되는데, 이는 패키징 실수가 요란하게 실패하는 대신 Windows의 고아 구멍을 조용히 다시 연다는 뜻입니다. 그래서 이 복사는 asset이 없으면 실패하는 빌드 단계이지, 바람이 아닙니다.