Writing
← Writing

tmux 화면을 파싱해서 폰에서 프롬프트에 답하기

지난 글에서 “세션이 검수 대기가 되면 그 tmux 화면을 읽어서 질문과 보기를 실제 디스코드 선택 버튼으로 만든다”고 적었습니다. 그 화면을 읽는 부분이 생각보다 까다로웠는데, 실측으로 하나씩 메운 함정들을 남겨둡니다.

왜 화면을 읽어야 하나

Claude Code는 API가 아니라 터미널 TUI입니다. 권한 프롬프트나 질문이 뜨면 그건 구조화된 데이터가 아니라 tmux 페인에 그려진 화면으로 존재합니다. 원격에서 그 프롬프트에 답하려면, 결국 그 화면을 텍스트로 떠서 지금 뭘 묻고 있는지를 알아내야 합니다.

const pane = await capturePane(agent.tmuxTarget, 60); // 마지막 60줄
const parsed = parsePrompt(pane);

tmux capture-pane으로 페인 텍스트를 뜨는 것까진 쉽습니다. 문제는 그 텍스트가 사람 눈으로 보라고 그려진 것이라, 프로그램이 읽기엔 함정이 많다는 것입니다.

두 종류부터 가른다 — 단일 vs 다중

실제로 화면을 떠보니 선택 UI가 두 종류였습니다.

단일선택 (권한·신뢰 확인)                다중선택 (multiSelect 질문)
  ❯ 1. Yes, I trust this folder            ❯ 1. [ ] 코드 작성/수정
    2. No, exit                              2. [ ] 코드 이해/탐색
  Enter to confirm · Esc to cancel             Submit
                                           Enter to select · ↑/↓ to navigate

가르는 기준은 체크박스([ ])의 유무입니다. 체크박스가 있으면 다중, 없으면 단일. 이걸 왜 굳이 가르냐면 —

함정 1 — 숫자키 의미가 정반대입니다

같은 “1” 키가 두 UI에서 완전히 다르게 동작합니다.

  • 단일선택: 숫자 = 즉시 확정. 1을 누르면 그 순간 1번이 제출됩니다.
  • 다중선택: 숫자 = 체크 토글. 1은 1번을 켰다 껐다 할 뿐이고, 제출은 따로 해야 합니다.

그래서 종류를 틀리면 엉뚱한 답이 제출됩니다. 게다가 단일선택에서 숫자 뒤에 Enter를 덧붙이면, 그 Enter가 프롬프트 밖으로 새어나가 버립니다. 그래서 키 시퀀스를 종류별로 다르게 만들어야 했습니다.

export function keysFor(parsed, wantedNumbers) {
  const want = new Set(wantedNumbers.map(Number));
  if (parsed.kind === 'single') {
    const n = [...want][0];
    return n ? [String(n)] : []; // 숫자 하나로 끝. Enter 붙이면 새어나감
  }
  const keys = [];
  for (const o of parsed.options) {
    // 토글이니까, 지금 상태와 원하는 상태가 다른 것만 누른다
    if (want.has(o.n) !== o.checked) keys.push(String(o.n));
  }
  keys.push('Right', '1'); // Submit 탭으로 이동 → 제출
  return keys;
}

다중선택에서 이미 원하는 대로 체크된 건 건드리지 않는다는 게 포인트입니다. 전부 다 누르면 원래 켜져 있던 게 꺼져버리니까, 현재 상태와 다른 것만 토글합니다.

함정 2 — 놀고 있는 세션을 프롬프트로 오해하기

이게 제일 위험했습니다. 세션이 놀고 있을 때도 본문에 번호 목록이 있을 수 있습니다. 예를 들어 Claude가 “1. 테스트 통과 2. 문서 갱신…” 하고 요약을 출력한 화면입니다. 여기다 대고 키를 쏘면, 멀쩡히 쉬고 있는 세션의 입력창에 숫자가 박혀버립니다.

진짜 선택 UI에는 항상 커서(❯)가 찍힌 옵션이 있습니다. 그래서 커서가 없으면 선택 UI가 아니라고 보고 버립니다.

// 커서(❯)가 찍힌 옵션이 없으면 그냥 본문의 번호 목록 → 파싱 포기
if (!hasCursor) return null;

// 번호가 1..N으로 안 이어지면 선택 UI가 아닐 가능성 → 포기
const nums = options.map((o) => o.n);
if (nums[0] !== 1 || nums.some((v, i) => v !== i + 1)) return null;

번호가 1, 2, 3…으로 연속하지 않으면(중간에 튀면) 그것도 그냥 번호 목록으로 보고 포기합니다. 확실하지 않으면 아무것도 안 하는 쪽으로 계속 기울였습니다. 원격으로 키를 쏘는 일이라, 애매할 땐 안 하는 게 낫기 때문입니다.

함정 3 — 다중선택은 질문이 여러 개일 수 있습니다

멀티셀렉트 질문은 한 화면에 질문이 여러 개(최대 몇 개) 뜰 수 있습니다. 탭 바의 ☐/☒ 개수가 곧 질문 개수라, 하나일 때만 안전하게 몰 수 있습니다. 질문이 여러 개면 파싱으로 자동화하지 않고 폰 키패드로 직접 누르는 폴백에 맡깁니다.

함정 4 — 맥락을 어디까지 긁어올까

옵션만 보내면 “1번이 뭔데?”가 됩니다. 옵션 위의 맥락도 같이 읽어야 하는데, 여기서도 걸렸습니다. 권한 프롬프트는 무슨 명령을 실행하려는지가 옵션 위에 적혀 있는데, 빈 줄에서 끊어버리면 정작 실행할 명령이 사라집니다 — Bash 권한 프롬프트는 명령과 질문 사이가 비어 있어서, “Do you want to proceed?” 한 줄만 남고 위의 curl ...이 날아가 버리기 때문입니다.

그래서 빈 줄이 아니라 프롬프트 박스의 경계(가로줄 ─)까지 거슬러 올라가서 맥락을 모읍니다. 탭 바 같은 UI 장식은 걸러냅니다. “폰에서 보고 승인 여부를 판단”하려면 이 맥락이 제일 중요하기 때문입니다.

함정 5 — 렌더 레이스

마지막으로, 알림이 뜨는 바로 그 순간엔 Claude Code TUI가 아직 프롬프트 박스를 다 안 그린 상태일 수 있습니다. 그 타이밍에 화면을 캡처하면 빈 화면을 잡아서 파싱이 실패합니다.

for (let attempt = 0; attempt < 6; attempt++) {
  const pane = await capturePane(agent.tmuxTarget, 60);
  const parsed = parsePrompt(pane);
  if (parsed) return promptPayload(agent, parsed); // 실제 선택 버튼으로
  await sleep(350); // 아직 안 그려짐 → 잠깐 기다렸다 다시
}
return reviewEmbed(agent); // 끝내 실패하면 단순 [승인][거부] 카드로 폴백

파싱될 때까지 짧게 몇 번 재시도하고, 그래도 안 되면(자유 입력 요청 같은 경우) 단순한 승인/거부 카드로 폴백합니다. 조종은 어떻게든 계속 되게 하는 게 목적이라, 완벽한 파싱보다 폴백이 있는 게 중요했습니다.

돌아보면

  • 화면은 사람이 보라고 그린 거라, 프로그램으로 읽으면 계속 부딪힙니다. 커서 위치, 빈 줄, 렌더 타이밍 — 사람은 무심코 넘기는 걸 코드는 하나하나 정해줘야 했습니다. 결국 실측해서 함정을 하나씩 메우는 수밖에 없었습니다.
  • 애매하면 안 하는 쪽으로. 원격으로 키를 쏘는 일이라, 오탐으로 놀고 있는 세션을 건드리는 것보다 한 번 놓치는 게 나았습니다. 커서·번호 연속성 검사는 다 “확신 없으면 포기”를 위한 장치입니다.
  • 이런 함정은 겪는 순간 바로 적어두는 게 나은 것 같습니다. 주석에 “왜 이 검사가 있는지”를 남겨두니, 나중에 리팩터링하다가 무심코 지웠다가 다시 당하는 걸 막아주었습니다.