콘텐츠로 이동

세션과 이벤트

원인. 디스크에 존재하지 않는 id로 CLI를 --resume <id>와 함께 spawn했습니다.

흔한 발단은 “이것이 resume인가?“를 id의 존재 여부로 추론하는 것입니다. 모든 spawn은 id를 갖고 있으므로, 그 추론은 모든 세션을 resume으로 만들고, 그러면 첫 세션은 언제나 실패합니다. 이것은 실제 회귀였고 실제 CLI를 상대로만 잡혔습니다. isResuming()resume을 명시적으로 요구하는 이유입니다.

new SpawnOptions({ sessionId: 'abc' }).isResuming(); // false — 새 세션
new SpawnOptions({ sessionId: 'abc', resume: true }).isResuming(); // true — 이어가기

해결. 새 대화에는 Command.open()을, 이미 존재하는 대화에는 Command.resume(sessionId, …)를 사용하십시오. 커스텀 어댑터가 관여한다면, --resume--session-id 중 무엇을 쓸지 options.sessionId !== null이 아니라 options.isResuming()으로 고르는지 확인하십시오.

const fresh = Command.open(Claude, { workingDir: '/proj' });
const later = Command.resume(fresh.sessionId, Claude, { workingDir: '/proj' });

반대 오류에는 별도의 메시지가 있습니다. 이미 디스크에 있는 id로 새 세션을 시작하려 하면 사용 중이라며 거부됩니다.

다음 순서대로 확인하십시오.

확인 방법
send()를 호출하기는 했는가? 세터는 쌓기만 합니다. 첫 send() 전까지 command.isLive()false입니다.
프로세스가 시작되었는가? command.session?.pidnull이 아닌지
즉시 종료되었는가? command.session?.isClosed()를 보고, command.session?.diagnostics를 읽으십시오
리스너를 제때 등록했는가? subscribesend() 전에 호출하십시오. Command.open이 아무것도 시작하지 않는 이유가 이것입니다 — 이벤트를 놓칠 수 있는 구간이 아예 없습니다.
출력이 NDJSON인가? parseLine은 JSON이 아닌 모든 줄에 null을 반환하고, 그런 줄은 조용히 건너뜁니다

JSON이 아닌 것을 건너뛰는 것은 의도된 동작입니다. CLI는 프로토콜과 같은 스트림에 경고를 쓰며, 그것을 실패로 취급하는 호스트는 CLI가 말이 많아진 버전에서 깨집니다. 무엇이 버려지는지 보려면 직접 파싱하십시오.

const parser = new EventParser();
for (const line of buffer.push(chunk)) {
if (parser.parse(line) === null) console.warn('not protocol output:', line);
}

이 라이브러리가 모델링하지 않은 엔트리 타입은 원인이 아닙니다. 그것은 버려지지 않고 GenericEvent로 도착하며, 페이로드는 raw 아래에 온전히 담깁니다.

원인. send()를 호출한 적이 없습니다. 세터는 의도를 쌓고 this를 반환할 뿐, 아무것도 시작하지 않습니다. 이는 전체 구조가 설계대로 동작하는 것이며, 가장 흔한 첫 놀라움입니다.

command.setMessage('fix the bug'); // 아직 아무것도 실행되지 않았습니다
await command.setMessage('fix the bug').send(); // 이제 실행됐습니다

확인. command.isLive()false이고, command.inspect()(not started)로 끝납니다.

원인. 닫힌 집합 밖의 이름이 setPermissions()에 도달했습니다. 유효한 이름은 plan, ask_before_edit, auto_edit, auto, bypass이며, 언더스코어 대신 하이픈도 허용됩니다.

기본값으로 넘어가지 않고 throw하는 이유. 호출자가 요청한 것과 다른 모드로 조용히 실행되는 것은 아무도 승인하지 않은 편집으로 끝나는 종류의 실수입니다. 오타가 “어차피 CLI가 하려던 것”으로 바뀌는 것은 안전한 실패가 아닙니다.

command.setPermissions('ask-before-edit'); // 정상
command.setPermissions('acceptEdits'); // throw — 그것은 CLI의 플래그 철자이지
// 라이브러리의 모드 이름이 아닙니다

마지막 줄이 흔한 원인입니다. acceptEdits는 Claude CLI가 와이어에서 그 모드를 부르는 이름이고, ClaudePermissionFlag가 이를 번역합니다. 여기에 전달할 이름은 auto_edit입니다.

두 번째 턴에 첨부가 함께 가지 않음

섹션 제목: “두 번째 턴에 첨부가 함께 가지 않음”

이는 설계에 의한 것입니다. send()는 첨부를 비웁니다. 첨부는 그것을 실어 나른 턴에 속하며, 조용히 재전송되는 것은 놀라운 일이기 때문입니다.

await command.setAttachment('/proj/a.ts').setMessage('explain this').send();
command.attachments; // [] — 이미 비워졌습니다
await command.setAttachment('/proj/a.ts').setMessage('now refactor it').send();

또한 setAttachment(단수)는 목록에 추가하는 것이 아니라 전체를 교체합니다. 여러 개를 보내려면 setAttachments([…])를 사용하십시오.

model이나 permission 변경이 효과가 없음

섹션 제목: “model이나 permission 변경이 효과가 없음”

원인은 어느 쪽이냐에 따라 다릅니다.

설정 적용 시점 이유
setModel() 다음 spawn --model은 spawn 플래그입니다. 살아 있는 프로세스는 session.setModel()로 컨트롤 채널을 통해 대상을 바꿀 수 있지만, 그것은 이미 실행 중인 것에만 닿습니다.
setPermissions() 다음 send()이때 재spawn됩니다 --permission-mode는 spawn 시점 전용이고 실행 중에 바꾸는 문서화된 방법이 없으므로, send()가 기존 프로세스를 물러나게 하고 resume: true로 새 프로세스를 시작합니다.
setProvider() 다음 send() — 이때 재spawn됩니다 CLI 프로세스는 다른 CLI가 될 수 없습니다.

따라서 permission 변경은 자동으로 반영되는 반면, 대화 도중의 model 변경은 세션에 직접 요청해야만 실행 중인 프로세스에 닿습니다.

command.setModel('opus'); // 다음 spawn을 위해 고정
command.session?.setModel('opus'); // 그리고 살아 있는 프로세스의 대상도 변경

두 경로가 모두 존재하는 이유는 어느 하나만으로는 충분하지 않기 때문입니다. 하나는 실행 중인 프로세스에 닿고, 다른 하나는 프로세스보다 오래 살아남습니다.

JSON 파싱 실패, 또는 분류되지 않는 이벤트

섹션 제목: “JSON 파싱 실패, 또는 분류되지 않는 이벤트”

원인. 프로토콜 스트림 위의 ANSI 이스케이프 시퀀스입니다. 자신에게 유능한 터미널이 붙어 있다고 믿는 CLI는 출력을 장식하는데, \x1b[1 q{"type":…은 파싱 가능한 JSON이 아닙니다.

해결. 이것을 막는 것이 TERM=dumb이며, ClaudeAdapter는 모든 spawn에 이를 설정합니다. 그럼에도 이 현상이 보인다면, 커스텀 어댑터가 그 바닥값을 생략했거나 호출자 자신의 env가 이를 덮어쓴 것입니다 — 설계상 호출자의 env는 프로토콜 env 위에 병합되므로 그렇게 될 수 있습니다.

// 이것은 파싱을 다시 망가뜨립니다:
Command.open(Claude, { env: { TERM: 'xterm-256color' } });

CI=true도 같은 이유로 중요하며, CLAUDECODE는 설정하는 것이 아니라 의도적으로 비웁니다 — 이를 상속받으면 CLI가 중첩 실행이 아닌데도 중첩 실행처럼 동작합니다.

이것이 맞는 답일 수 있습니다. MCP가 설정되지 않은 것이 평범한 경우이고, 이는 CLI 자신의 출력에서 실패와 구분되지 않습니다 — 그래서 list()는 흔한 경우에 throw하는 대신 둘 다에 []로 답합니다.

라이브러리가 실행하는 명령을 직접 실행해 확인하십시오.

const result = await command.exec(['mcp', 'list']);
console.log(result.stdout, result.stderr, result.isOk());
보이는 것 의미
빈 stdout 정말로 아무것도 설정되지 않음
서버 줄은 있는데 list()는 여전히 비어 있음 파싱 실패 — 줄이 읽히려면 : 와 마지막 가 필요합니다
stderr에 오류 CLI가 열거하지 못함. scope나 설정에 문제가 있을 수 있습니다

예상한 모양에 맞지 않는 줄은 전체 목록을 실패시키는 대신 건너뛰므로, 부분적인 결과는 일부 줄은 파싱되고 나머지는 되지 않았다는 뜻입니다.

서버를 제거했는데 여전히 목록에 있음

섹션 제목: “서버를 제거했는데 여전히 목록에 있음”

원인. scope를 생략한 것입니다. project 및 local scope 서버의 경우 CLI가 엉뚱한 설정 파일을 보고 제거할 것을 찾지 못하며, 그에 대해 별다른 보고도 하지 않습니다.

await command.mcp.remove('playwright', { scope: 'project' });
증상 원인 해결
브라우저가 두 번 열림 CLI뿐 아니라 호스트도 URL을 열었습니다 URL을 보여주기만 하고 열지 마십시오. CLI가 이미 시도하며, 성공했는지는 보고하지 않습니다.
사용자에게 코드는 있는데 붙여넣을 곳이 없음 호스트가 코드가 필요 없다고 추론했습니다 항상 입력 필드를 제공하십시오. 코드 필요 여부는 브라우저 쪽에서 결정되며 출력에 절대 나타나지 않습니다.
onUrl이 발화하지 않음 login.output을 읽으십시오. 두 스트림을 모두 훑으므로 CLI가 출력한 것은 거기에 있습니다.

describe()supportsModel()이 쓸모 있는 값을 반환하지 않음

섹션 제목: “describe()나 supportsModel()이 쓸모 있는 값을 반환하지 않음”
결과 의미
describe()null CLI가 타임아웃(기본 15초) 내에 답하지 않았습니다. 오래된 CLI는 initialize 컨트롤 요청을 아예 지원하지 않을 수 있습니다 — throw할 실패가 아니라 다뤄야 할 사실입니다.
동작하는 모델인데 supportsModel()false 프로브 턴이 무관한 이유로 실패했습니다 — 인증 없음, 네트워크 없음. 실제 요청을 보내므로 요청을 깨뜨리는 것은 무엇이든 프로브도 깨뜨립니다.
supportsModel()이 느림 필연적으로 요청 비용이 듭니다. “이 모델을 써도 되는가”에 답하는 명령은 없으며, 자격은 계정과 조직에 달려 있기 때문입니다. 렌더링마다 프로브하지 말고 답을 캐시하십시오.

둘 다 임시(--no-session-persistence)로 실행되므로, 어느 쪽도 사용자의 트랜스크립트에 아무것도 남기지 않습니다.

원인. 스트림 플래그가 없거나 불완전합니다. --input-format stream-json이 없으면 stdin이 열린 채로 유지되지 않으므로, CLI는 그 실행을 단발성 프롬프트로 취급하고 대화를 이어갈 수 없습니다.

해결. 커스텀 어댑터는 전체 세트를 포함해야 합니다.

-p --output-format stream-json --input-format stream-json
--verbose --include-partial-messages --permission-prompt-tool stdio

원인. --permission-prompt-tool stdio가 없어 승인 요청이 호스트가 읽는 채널로 라우팅되지 않습니다.

확인. 이벤트가 분류되고 있는지 확인하십시오.

command.subscribe((event) => {
console.log(event.type, event.constructor.name);
});

control_request 엔트리는 PermissionRequestEvent로 분류되어야 합니다.

관련된 실패: 요청은 도착하고 호스트도 답했는데 CLI가 그것을 무시하는 경우입니다. 원인은 둘이며, 둘 다 이벤트 자신의 approve() / deny()와 함께 onPermission()을 쓰면 피할 수 있습니다.

원인 상세
응답에 일치하는 request_id가 없었음 CLI가 둘을 짝지으며, 짝이 없는 응답은 아무 데도 가지 않습니다. 그 id는 요청과 함께 도착했고, 그래서 응답을 요청 위에서 만듭니다.
응답 봉투가 평평해짐 봉투는 두 겹입니다 — 어느 요청이 성공했는지 말하는 바깥 봉투가 결정을 감쌉니다. 평평하게 만들면 불평 없이 받아들여진 뒤 error_during_execution으로 턴이 실패하며, 왜인지는 아무것도 말해주지 않습니다.
command.onPermission((request) => request.approve());
// { type: 'control_response',
// response: { subtype: 'success', request_id: '…',
// response: { behavior: 'allow', updatedInput: {} } } }

이벤트에 필드가 빠진 것처럼 보임

섹션 제목: “이벤트에 필드가 빠진 것처럼 보임”

필드를 걷어내고 있는 것이 아닙니다. raw는 어떤 필드도 제거하거나 이름을 바꾸지 않은 CLI 자신의 엔트리이므로, CLI가 보낸 것은 무엇이든 그대로 있습니다.

event.raw['cost_usd'];
event.raw['unknown_future_field'];

정말로 필드가 없다면 CLI가 그것을 보내지 않은 것입니다. 타입이 있는 접근자 — requestId, subtype, reportedPermissionFlag() — 가 null을 반환한다면, 그 필드가 없었거나 문자열이 아니었다는 뜻이지 버려졌다는 뜻이 아닙니다.

실행의 마지막 이벤트가 유실됨

섹션 제목: “실행의 마지막 이벤트가 유실됨”

이런 일은 일어나지 않아야 하며, 일어난다면 원인은 Session이 아니라 직접 만든 read 루프입니다.

끝에 개행 없이 종료하는 CLI는 마지막 이벤트를 버퍼에 남깁니다. Session은 close 시 close 리스너에게 알리기 전에 NdjsonBuffer.flush()를 호출하므로 그 이벤트도 전달됩니다. push()만 호출하는 손수 만든 루프는 그것을 잃습니다.

process.on('close', () => {
for (const line of buffer.flush()) emit(line); // 이 줄을 생략하지 마십시오
});

인터럽트 후 세션이 응답하지 않음

섹션 제목: “인터럽트 후 세션이 응답하지 않음”

이는 예상된 동작이며, 버그로 보이기 전에 알아둘 만합니다.

claude 2.1.170을 상대로 실측한 결과입니다. CLI는 컨트롤 채널로 답하고 subtype이 error_during_executionResultEvent로 턴을 끝냅니다 — 여기서 이는 고장이 아니라 인터럽트됨을 뜻합니다. 그 후 프로세스는 살아 있지만 더 이상 아무것도 처리하지 않습니다. 이후에 보낸 턴은 응답을 받지 못하고, 프로세스 종료를 기다리면 영원히 기다립니다.

해결. 인터럽트하고, 정지시키고, 새 세션에서 이어가십시오. 대화는 디스크에 남아 있고, 소진된 것은 프로세스뿐입니다.

command.subscribe((event) => {
if (event instanceof ResultEvent) command.close();
});
command.interrupt();
// 같은 대화, 새 프로세스.
const next = Command.resume(command.sessionId, Claude, { workingDir });

그럼에도 close()만 호출하는 것보다 인터럽트가 낫습니다. CLI가 쓰기 도중에 죽는 대신 자신의 result를 쓰고 트랜스크립트 엔트리를 닫기 때문입니다.

원인. 컨트롤 채널은 살아 있고 응답 가능한 프로세스에만 닿습니다. 멈춰버린 CLI는 그에 답하지 않습니다.

해결. 응답 없는 인터럽트 뒤에는 close()를 이어 하십시오. 컨트롤 채널은 프로세스를 죽이는 것에 대한 최적화이지 그 대체재가 아닙니다 — Interrupt는 설계상 자체 폴백이 없습니다.

command.interrupt();
setTimeout(() => {
if (command.isLive()) command.close();
}, timeoutMs);

session.setModel()에도 같은 것이 적용됩니다. 이는 실행 중인 프로세스에만 닿습니다. 아무것도 실행되지 않는 동안 고른 모델은 command.setModel()로 다음 spawn을 위해 고정해야 합니다.

쓰기가 아무 데도 가지 않는 것처럼 보임

섹션 제목: “쓰기가 아무 데도 가지 않는 것처럼 보임”

원인. 세션이 닫혔습니다. CLI가 종료된 뒤의 쓰기는 의도적인 no-op입니다. 죽은 프로세스에 쓰면 EPIPE가 발생하는데, 종료와 경합한 호출자가 잘못한 것은 없기 때문입니다.

확인. command.isLive(), 또는 command.session?.isClosed().