← 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); // 끝내 실패하면 단순 [승인][거부] 카드로 폴백

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

돌아보면

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