진짜 PTY — 텔레타이프에서 에이전트 오케스트레이션까지

진짜 PTY — 텔레타이프에서 에이전트 오케스트레이션까지

Herdr 문서의 한 문장에서 시작해 50년을 거슬러 올라간 기록. 함께 볼 문서: Herdr 개요 (레퍼런스) · 내 환경 × Herdr 도입 가이드 (실행 가이드)

이 문서는 순서대로 읽히도록 썼습니다. 각 절은 지금 겪는 증상에서 출발해 그 원인이 된 역사로 들어갑니다. 절마다 직접 쳐볼 명령과, 더 파고들 만한 질문을 붙여뒀습니다.


1. 문장 하나

Herdr 문서에 이런 자랑이 있습니다.

각 pane은 portable-pty 기반의 진짜 PTY라서 에이전트가 일반 터미널과 정확히 동일하게 동작합니다 — webview나 이스케이프 시퀀스가 빠진 유사 터미널이 아닙니다.

읽고 넘어가기 쉬운 문장입니다. 그런데 여기에 세 가지가 숨어 있습니다.

  • PTY가 뭔데 "진짜"와 "가짜"가 나뉘는가
  • 왜 그게 자랑거리인가 — 안 그런 도구가 있다는 뜻인데
  • 그리고 이게 님이 실제로 겪는 문제들, 그러니까 긴 응답이 잘려서 안 읽히는 것, 창을 닫아도 에이전트가 안 죽는 것, 상태 감지가 가끔 틀리는 것과 어떻게 연결되는가

세 번째가 이 문서의 목적입니다. 결론부터 말하면 저 세 증상은 전부 1970년대의 설계 결정이 지금까지 살아남은 결과입니다. 역사를 아는 게 취미가 아니라 디버깅 배경지식이 되는 드문 경우예요.


2. PTY — 커널이 만드는 가짜 터미널

PTY는 pseudo-terminal, 의사 터미널입니다. 커널이 제공하는 가짜 터미널 장치 한 쌍이에요.

한쪽을 master, 다른 쪽을 slave라고 부릅니다. 요즘은 primary/secondary라고도 씁니다.

[터미널 에뮬레이터]  ←→  [커널 PTY]  ←→  [셸 · nvim · 에이전트]
   master 쪽 소유                          slave = stdin/stdout/stderr

동작은 단순합니다.

  • 셸이 slave에 쓰면 → master 쪽에서 읽힙니다 → 에뮬레이터가 화면에 그립니다
  • 에뮬레이터가 master에 쓰면 → slave 쪽에서는 사용자가 키보드로 친 것과 구분되지 않습니다

핵심은 커널이 진짜 터미널로 취급한다는 것

slave 쪽은 커널 입장에서 진짜 터미널 장치입니다. 그래서 이런 게 공짜로 따라옵니다.

  • isatty()가 참을 반환 → 프로그램이 색상과 TUI 모드를 켬
  • 터미널 크기를 물어볼 수 있고(TIOCGWINSZ), 크기가 바뀌면 신호가 옴(SIGWINCH)
  • line disciplineCtrl+C가 포그라운드 프로세스 그룹에 SIGINT를 보내는 것, Ctrl+Z, 에코, 행 편집이 전부 커널의 이 계층에서 처리됩니다
  • job control, controlling terminal, 세션 리더
  • 이스케이프 시퀀스 — 커서 이동, 색상, 그리고 alternate screen

line discipline이란 — 커널이 터미널 입출력 사이에 끼워 넣는 처리 계층입니다. Ctrl+C를 문자가 아니라 신호로 바꾸고, 백스페이스를 처리하고, 엔터를 칠 때까지 입력을 모아뒀다가 한 줄씩 프로그램에 넘깁니다. stty raw를 하면 이 계층이 대부분 꺼지고 키가 날것으로 프로그램에 갑니다. nvim이 시작할 때 하는 일이 정확히 이겁니다.

"가짜 터미널"이 있다는 것

일부 에이전트 관리 도구는 프로세스를 파이프로 실행하고 출력을 웹뷰에 예쁘게 렌더링합니다. 그러면 isatty()가 거짓이 되고, 프로그램이 TUI를 안 켜고, 이스케이프 시퀀스를 절반만 해석하고, Ctrl+C가 다르게 동작합니다.

Herdr가 "진짜 PTY"를 강조하는 게 이 대비입니다. 프로그램이 차이를 알 수 없다는 게 실질적 약속이에요. nvim, lazygit, Claude Code의 TUI가 wezterm에서와 똑같이 돌아갑니다.

직접 확인

tty
# /dev/pts/3

ls -l $(tty)
# crw--w---- 1 you tty 136, 3 ...
#                       ^^^^^^
#                    major, minor 장치 번호

파일 크기 자리에 136, 3이 들어있는 게 보입니다. 이건 크기가 아니라 장치 번호예요. 이 파일에는 데이터 블록이 없습니다. 커널이 "136번 드라이버에게 넘겨라"로 해석할 뿐입니다.

python3 -c "import sys; print(sys.stdout.isatty())"
# True

python3 -c "import sys; print(sys.stdout.isatty())" | cat
# False   ← 파이프에 물리면 터미널이 아님

두 번째가 헤드리스 모드와 Herdr pane의 차이입니다. claude -p ... | cat은 PTY가 아니라 파이프라서 대화형 UI가 안 뜹니다. Herdr는 PTY를 주기 때문에 평소 쓰던 CLI 그대로 돌아가고, 그래서 승인 프롬프트도 정상적으로 떠서 사람이 직접 눌러줄 수 있습니다.

더 파볼 만한 것stty raw-icanon이 정확히 뭘 끄는가 / 프로세스 그룹과 세션의 관계 / /dev/ptmx로 PTY를 직접 열어보기


3. TTY의 Y — 종이에 찍히던 시절

PTY는 Pseudo-TTY입니다. 그럼 TTY는?

TeleTYpewriter의 자음 골격만 남긴 겁니다. 전신 시대에 전송 비용을 아끼려고 모음을 빼는 축약이 관행이었어요. TWX(TeletypeWriter eXchange)도 같은 방식입니다.

1960~70년대 초기 유닉스의 단말은 진짜 텔레타이프였습니다. Teletype Corporation의 ASR-33 같은 기계 — 전동 타자기에 키보드가 달린 물건이요.

구성이 이게 전부입니다.

  • 키보드 (누르면 전기 신호)
  • 활자 헤드 (신호 받으면 종이에 찍음)
  • 종이 롤
  • 시리얼 선

CPU도 메모리도 저장장치도 없습니다. 초당 10글자(110 baud)로 문자를 주고받는 게 능력의 전부였어요. 화면이 없으니 출력이 전부 종이로 나왔고, ls를 치면 결과가 드르륵 인쇄됐습니다.

그럼 파일시스템은 어디에

컴퓨터 본체 쪽에만 있었습니다.

[PDP-11 본체]                          [텔레타이프]
  디스크 팩 (RK05, 5MB쯤)      ←→        저장장치 없음
  커널 + tty 드라이버         110 baud    키보드 + 활자 + 종이
  /dev/tty 파일이 여기 있음

/dev/tty는 컴퓨터의 디스크에 있는 파일 이름이고, 실제 동작은 커널 드라이버가 하고, 데이터는 시리얼 선을 타고 갑니다. 텔레타이프에는 파일이라는 개념 자체가 없었어요.

이게 유닉스의 "모든 것은 파일이다" 원칙입니다. 프로그램은 open()/read()/write()만 알면 되고, 그게 디스크 파일인지 텔레타이프인지 신경 쓸 필요가 없습니다.

콘솔은 또 뭔가

단말기 중에서 특별 취급받는 하나입니다. 기계실에 텔레타이프가 여러 대 붙어 있으면 대부분은 그냥 사용자 단말이고, 그중 본체에 물리적으로 붙어 특권을 가진 하나가 콘솔이에요. 부팅 메시지와 커널 패닉이 여기로 나가고, 싱글 유저 모드에서 유일하게 살아있습니다.

어원 자체가 기계의 조작반입니다. PDP-11 앞면에는 토글 스위치와 램프가 줄줄이 박힌 프론트 패널이 있었고, 부트 주소를 손으로 스위치를 올려 입력했습니다. 그 옆에 놓인 텔레타이프가 콘솔 텔레타이프였던 거죠.

지금도 구분이 남아 있습니다.

파일의미
/dev/console시스템 콘솔. 커널 메시지가 가는 곳
/dev/tty내 프로세스의 controlling terminal
/dev/pts/33번 PTY 슬레이브

/dev/tty가 특히 재밌습니다. 고정된 장치가 아니라 "지금 이 프로세스를 소유한 터미널"로 해석돼요. wezterm 창 두 개에서 각각 echo hi > /dev/tty 하면 각자 자기 창에 찍힙니다.

왜 이런 구성이었나

기계가 너무 비쌌습니다. PDP-11이 1970년대에 몇만 달러, 지금 가치로 수억 원대였어요. 텔레타이프는 훨씬 쌌고요. 비싼 계산 자원 하나에 싼 입출력 장치 여러 개를 매다는 것이 유일하게 합리적인 구성이었습니다. 이게 time-sharing(시분할) 입니다.

그 전에는 배치 처리였습니다. 프로그램을 천공 카드에 뚫어서 창구에 제출하고, 다음 날 출력물을 찾으러 가고, 세미콜론 하나 빠진 걸 보고 처음부터 다시. 디버깅 한 사이클이 하루였습니다. 시분할의 약속은 단 하나였어요 — 몇 초 안에 답을 받는 것.

여담: 유닉스는 문서 작성 시스템으로 예산을 땄다

벨 연구소에서 톰슨과 리치가 PDP-11 구매를 승인받으려 했는데, "운영체제를 만들겠다"로는 예산이 안 나왔습니다. 그래서 특허 부서의 문서 작성 시스템을 만들겠다고 제안했어요. 벨 연구소는 특허를 엄청나게 냈고 타이피스트들이 출원서를 계속 쳐야 했으니, 이건 명확한 사업적 필요였습니다.

승인이 났고, 실제로 roff를 만들어 타이피스트들에게 줬습니다. 그리고 그 기계 위에서 유닉스를 계속 개발했죠. 초기 유닉스의 첫 실사용자는 프로그래머가 아니라 특허 부서 타자수들이었습니다.

더 파볼 만한 것 — 천공 카드 워크플로 / PDP-11 프론트 패널로 부팅하기 / 왜 /dev가 파일시스템 안에 있는가


4. 초당 10글자가 만든 것들

이 절이 유닉스 관습의 대부분을 설명합니다.

명령어가 짧은 이유

110 baud면 초당 10글자입니다. 파일 40개짜리 디렉터리를 ls 하면 360자쯤 되고, 36초 동안 기계가 덜컹거립니다.

ls, cp, mv, rm, ed 같은 두세 글자 이름은 인색함의 미학이 아니라 실질적 절약이었어요. 명령어를 다섯 글자로 지으면 타이핑 시간과 에코 시간이 매번 더 듭니다.

당시 ls한 줄에 하나씩 출력했습니다. 지금 익숙한 여러 열로 채워 넣는 동작은 화면 단말이 생긴 뒤 BSD에서 들어온 겁니다.

ed — 보이지 않는 파일을 편집하는 법

"종이에 어떻게 코드를 편집하냐"가 제일 안 와닿는 부분일 텐데, 답은 파일을 안 보고 편집한다입니다.

$ ed main.c
1247                    ← 바이트 수만 알려줌. 그게 다
/malloc/p               ← malloc 있는 행 찾아서 출력
    p = malloc(n);
s/n/n+1/p               ← 치환하고 결과만 출력
    p = malloc(n+1);
w                       ← 저장
1249
q

파일 내용이 화면에 떠 있지 않습니다. 필요할 때 필요한 행만 인쇄시켜요.

  • 명령이 한 글자 — 매 글자가 초당 10자로 인쇄되니까
  • 시키지 않으면 아무것도 안 찍음 — 종이가 아깝고 느리니까
  • 에러 메시지가 ? 하나 — 무슨 에러인지 안 알려줍니다. 긴 에러 메시지를 인쇄할 여유가 없었어요

그리고 여기서 위대한 파생물이 나옵니다. ed에서 "전역적으로 정규식 찾아서 출력"이 이 명령이었습니다.

g/re/p

g/re/p → grep. 파일을 눈으로 훑어볼 수 없으니 기계에게 찾게 시켜야 했고, 그 필요가 도구가 됐습니다. sed(stream editor)도 같은 문법 계보고, vi는 나중에 화면 단말이 생긴 뒤 ed의 확장인 ex 위에 붙은 "visual mode"입니다.

파이프

PDP-11의 주소 공간이 64KB였습니다. 거대한 프로그램을 못 만드니 작게 쪼개고 조합할 수밖에 없었어요. ls | grep | wc가 미학이기 전에 메모리 제약의 산물입니다.

/dev/tty의 진짜 쓸모

ls > /dev/tty라고 치면 종이에 찍혔습니다. 그런데 그냥 ls라고 쳐도 결과가 똑같았어요. stdout이 이미 그 터미널을 가리키고 있으니까요.

/dev/tty가 존재하는 이유는 따로 있습니다. stdout이 딴 데로 가버렸을 때도 사람에게 직접 말을 걸 수 있는 통로거든요.

echo "처리 중..." > /dev/tty     # 리다이렉트 무시하고 무조건 터미널로

myscript > output.txt로 실행해도 저 메시지만은 사람에게 갑니다. 실제 용례가 이런 것들이었어요.

  • 패스워드 입력passwdssh가 프롬프트를 /dev/tty에서 직접 읽습니다. echo pass | ssh ...가 안 통하는 이유입니다
  • 진행 상황 표시 — 출력은 파일로 보내면서 "3/100 완료"는 사람에게
  • 파이프라인 중간 확인cat file | tee /dev/tty | wc -l

>는 "stdout을 바꿔라", /dev/tty는 "바뀌었든 말든 터미널로"입니다. 층위가 달라서 둘 다 필요했습니다.

roff — 종이 시대의 조판기

이름은 RUNOFF의 축약이고, "글을 뽑아낸다(run off a copy)"는 인쇄소 표현에서 왔습니다. 원조는 1964년 CTSS의 제롬 솔처가 만든 프로그램이에요.

원리는 마크다운이나 LaTeX과 같습니다 — 평문에 지시를 섞어두면 프로그램이 배치를 계산합니다. 텔레타이프에서는 미리 보여줄 방법이 없으니, WYSIWYG이 아니라 "의도를 적고 렌더링을 맡기는" 방식밖에 없었어요.

.TL
An Essay on Pipes
.PP
This paragraph will be filled and justified
automatically, regardless of how I break
the lines in the source.
.SH
Conclusion

행 맨 앞의 .가 명령입니다. .PP는 문단, .SH는 소제목.

출력 장치에 따라 갈라졌습니다. nroff는 고정폭 장치(텔레타이프, 라인 프린터), troff는 사진식자기용이었어요. 같은 입력, 다른 백엔드 — 소스와 렌더러를 분리한다는 발상이 여기서 자리 잡았습니다.

그리고 흥미로운 부분. 수식이나 표는 troff 문법에 욱여넣기 어려우니 별도 언어를 만들고 전처리기로 붙였습니다.

tbl paper.ms | eqn | troff -ms
  • eqn — 수식을 말하듯이. x sup 2 over y → x²/y
  • tbl — 표
  • pic — 도형
  • refer — 참고문헌

각 전처리기는 자기 구역만 troff 명령으로 바꾸고 나머지는 그대로 흘려보냅니다. 파이프 철학이 문서 조판에서 먼저 실증된 셈이에요.

지금도 살아 있습니다.

man ls                          # 이거 roff입니다
zcat /usr/share/man/man1/ls.1.gz | head
# .TH LS 1
# .SH NAME
# ls \- list directory contents

50년 넘게 매일 실행되는 마크업입니다.

종이의 역설

스크롤백은 없는데 기록은 영구적이었습니다. 위로 되돌려 볼 수는 없지만 종이를 손으로 끌어올리면 됐어요. 세션 전체가 물리적으로 남았고 그게 그대로 문서가 됐습니다. 초기 유닉스 매뉴얼의 예제들이 실제 세션을 찍어낸 종이에서 나온 것들이죠.

Ctrl+S/Ctrl+Q도 여기서 왔습니다. 종이가 너무 빨리 넘어가서 읽을 수 없으면 눌러서 세웠어요. 요즘 실수로 Ctrl+S 눌렀다가 먹통 됐다고 느끼는 그 기능이, 원래는 종이 속도를 붙잡던 브레이크입니다.

더 파볼 만한 것ed를 실제로 써보기(ed는 지금도 설치돼 있습니다) / troff로 문서 하나 조판해보기 / 왜 정규식이 ed에서 나왔나


5. 화면의 등장 — 그리고 alternate screen의 탄생

여기서부터가 님이 지금 겪는 문제의 시작점입니다.

증상부터

Herdr 문서에 이런 경고가 있습니다.

--lines를 늘려도 완료된 응답이 더 안 나오면, 그 pane은 에이전트를 alternate screen에서 돌리고 있을 가능성이 높습니다. alternate screen을 떠난 행은 호스트 스크롤백에 들어가지 않으므로 라인 수를 늘려도 복구할 수 없습니다.

이게 왜 그런가.

종이에는 커서가 없었다

종이는 한 방향으로만 갑니다. "3행 20열로 이동"이라는 개념이 없어요. 그래서 텔레타이프 시절에는 화면 편집기가 존재할 수 없었습니다.

1970년대 후반 비디오 단말이 나옵니다. DEC의 VT100(1978)이 대표적이에요. 화면이 생기자 갑자기 필요해진 것들이 있었습니다.

  • 커서를 임의 위치로 이동
  • 화면 지우기, 일부만 지우기
  • 스크롤 영역 지정
  • 밝기, 반전, 나중엔 색상

문제는 어떻게 보내느냐였습니다. 단말과 컴퓨터를 잇는 건 문자 스트림 하나뿐이니까요. 답은 문자 스트림 안에 제어 명령을 섞는 것이었습니다. 이게 이스케이프 시퀀스입니다.

ESC [ 2 J          화면 지우기
ESC [ 10;20 H      커서를 10행 20열로
ESC [ 31 m         이후 글자는 빨강
ESC [ ?1049 h      alternate screen 진입

ESC(0x1B)로 시작하면 "이건 출력할 글자가 아니라 명령"이라는 뜻입니다. 1979년 ANSI X3.64로 표준화됐고, 지금 모든 터미널 에뮬레이터가 이걸 해석합니다.

직접 보시려면:

printf '\033[31mRED\033[0m\n'
printf '\033[2J\033[H'          # 화면 지우고 커서 홈으로 — clear와 같은 일

alternate screen

전체 화면 프로그램(vi, less, top)이 실행되면 화면 전체를 자기 것으로 씁니다. 그런데 프로그램이 끝나면 원래 셸 화면이 돌아와야 하잖아요.

그래서 터미널이 화면 버퍼를 두 개 갖게 됐습니다.

  • 메인 화면 — 평소 쓰는 것. 위로 밀려난 행이 스크롤백에 쌓임
  • alternate screen — 전체 화면 프로그램용. 스크롤백이 없음

ESC[?1049h로 들어가고 ESC[?1049l로 나옵니다. 나오면 alternate screen의 내용은 그냥 사라져요. vi를 끄면 원래 화면이 그대로 돌아오는 게 이것 때문입니다.

스크롤백에 안 쌓이는 게 버그가 아니라 설계 목적입니다. 그 화면은 임시 작업 공간이지 기록이 아니에요.

less /etc/hosts        # 들어갔다 q로 나오면
                       # less가 보여준 내용이 흔적도 없음

그래서 Herdr가 못 가져오는 것

Claude Code나 OpenCode 같은 전체 화면 TUI 에이전트가 alternate screen에서 돌면, 그 대화 내용은 애초에 스크롤백에 저장된 적이 없습니다. Herdr가 pane read --lines 500을 해도 없는 걸 가져올 수는 없어요.

폰트가 크거나 pane이 작을수록 잘 발생한다는 것도 이제 설명됩니다. alternate screen은 화면 크기만큼만 존재하니까, 화면이 작으면 위로 밀려난 내용이 더 빨리 사라집니다.

해결책이 "에이전트에게 파일로 쓰게 하고 그 파일을 읽는 것"인 이유도 이겁니다. 화면을 우회해서 진짜 저장소를 쓰는 거예요.

남은 부채: $TERM과 terminfo

단말기 제조사마다 이스케이프 시퀀스가 달랐습니다. VT100, ADM-3A, Wyse 50이 전부 다른 방언을 썼어요. 그래서 어떤 단말인지 알아야 올바른 시퀀스를 보낼 수 있었고, 그 데이터베이스가 termcap(BSD)과 terminfo(System V)입니다.

echo $TERM
# xterm-256color

infocmp | head -5      # 지금 단말이 뭘 지원한다고 주장하는지

물리적 단말기가 사라진 지 40년인데 이 체계가 그대로 남아 있습니다. SSH로 접속했을 때 화면이 깨지거나 백스페이스가 이상하게 동작하는 문제 대부분이 $TERM 불일치예요. 유닉스의 가장 널리 인정된 설계 부채 중 하나입니다.

더 파볼 만한 것infocmp로 두 단말 비교하기 / tput으로 시퀀스 직접 뽑아보기 / Kitty graphics protocol과 Sixel — 터미널에 이미지를 그리는 현대적 시도


6. 끊기는 세션 — 멀티플렉서가 존재하는 이유

증상부터

님이 관찰하신 것. wezterm을 껐다 켜고 herdr를 다시 실행했는데 에이전트가 그대로 살아 있었죠. 그리고 그게 "복원"이 아니라 "재부착" 이라고 정리했습니다.

왜 이게 가능한가.

SIGHUP — 전화가 끊기면

옛날에는 단말이 전화선으로 연결되기도 했습니다. 모뎀으로 다이얼해서 붙는 거죠. 그런데 전화가 끊기면(hang up) 그 단말에 딸린 프로세스들은 어떻게 되어야 할까요?

커널의 답은 이겁니다 — SIGHUP을 보낸다. 기본 동작은 종료입니다. 회선이 끊겼는데 프로세스가 좀비로 남으면 안 되니까요.

여기서 나온 것들:

nohup ./long-task &     # "no hangup" — 이 신호를 무시하고 계속 돌아라
disown -h %1            # 이미 돌고 있는 잡을 셸에서 떼어내기

nohup이라는 이름이 문자 그대로 "hangup 없음"입니다. 1970년대 전화선 끊김에 대응하려고 만든 명령이 지금도 그 이름으로 있어요.

그리고 이 구조를 정의하기 위해 session, process group, controlling terminal, foreground process group이라는 개념들이 생겼습니다. Ctrl+C를 눌렀을 때 정확히 누가 죽는지를 정하는 게 이 구조예요.

ps -o pid,ppid,pgid,sid,tty,comm
#                ^^^^ ^^^ ^^^
#         프로세스그룹  세션  터미널

그리고 누군가 이걸 뒤집었다

1987년 GNU Screen(올리버 라우만)이 나옵니다. 발상이 이겁니다.

PTY의 master를 내가 쥐고 있으면, 클라이언트가 끊겨도 slave 쪽 프로세스는 아무것도 모른다.

[내 터미널]  ←(끊겨도 됨)→  [screen 서버]  ←→  [PTY]  ←→  [셸]
                              master 소유

셸 입장에서는 자기 터미널이 여전히 멀쩡합니다. SIGHUP이 안 옵니다. 전화가 끊긴 게 아니라 중간에 끊기지 않는 대리인을 세워둔 거죠.

2007년 tmux(니콜라스 매리엇)가 같은 원리를 다시 구현했고, 2026년 Herdr가 거기에 에이전트 상태 인지를 얹었습니다.

그래서 님이 본 것

wezterm 창을 닫은 건 클라이언트를 뗀 것이지 서버를 죽인 게 아닙니다. Herdr 서버가 master를 계속 쥐고 있으니 slave 쪽 에이전트는 아무 일도 없었다는 걸 모릅니다.

복원 로직이 돌 일이 없었어요. 애초에 아무것도 안 죽었으니까.

진짜 복원 경로는 서버가 실제로 죽었을 때만 발동합니다.

herdr server stop     # ⚠️ 이때 비로소 pane 프로세스들이 죽음
herdr                 # 스냅샷 복원 — 구조만 돌아옴

그리고 여기서 Herdr가 tmux보다 한 걸음 더 갑니다. 네이티브 에이전트 세션 복원 — 통합이 설치된 에이전트는 자기 대화 세션 ID를 Herdr에 보고하고, 서버가 죽었다 살아나면 빈 셸이 아니라 그 세션을 재개합니다. 프로세스는 새로 뜨지만 맥락은 이어져요.

계보로 정리하면 이렇습니다.

전화 끊김 → SIGHUP → nohup
                   → screen (master를 서버가 쥔다)
                   → tmux
                   → Herdr (+ 에이전트 상태, + 네이티브 세션 재개)

더 파볼 만한 것ps -o sid,pgid로 세션 구조 직접 보기 / 왜 Ctrl+Zbg가 필요했나 / systemd가 로그아웃 시 프로세스를 죽이는 KillUserProcesses 논쟁


7. 터미널에는 구조가 없다 — 감지 문제의 뿌리

이 절이 아마 실무적으로 제일 유용할 겁니다.

증상부터

Herdr가 가끔 상태를 unknown으로 표시하거나, 작업 중인데 idle로 보거나, 승인 대기를 못 잡습니다. 왜 이게 어려운 문제인가.

터미널은 문자 격자다

터미널이 가진 건 문자 셀 격자와 커서가 전부입니다. 구조가 없어요.

Claude Code가 "승인하시겠습니까?" 박스를 그릴 때, 그건 박스라는 객체가 아닙니다. 이런 명령의 나열이에요.

커서 5행 10열로 → '┌' 출력 → '─' 40번 → '┐' → 6행 10열로 → '│' → ...

받는 쪽에는 의미가 남지 않습니다. 격자에 글자가 찍혔을 뿐이에요.

그래서 Herdr가 화면을 읽어서 상태를 맞히려는 시도는 렌더링 결과에서 의도를 역추론하는 겁니다. HTML 없이 스크린샷만 보고 "이거 폼이야, 버튼이야?"를 판단하는 것과 같아요.

왜 활동량으로도 안 되나

Herdr 제작자의 글에 이 부분이 잘 정리돼 있습니다. "화면이 바뀌면 working, 멈추면 idle" 같은 휴리스틱은 이렇게 깨집니다.

  • 스피너는 계속 돌 수 있다 — 의미 있는 상태가 blocked인데도
  • 권한 프롬프트는 가만히 있다 — pane에서 가장 중요한 것인데도 정적임
  • 서브에이전트가 활동을 만든다 — 부모 세션은 대기 중인데

터미널 활동은 증거일 뿐이고, 라이프사이클이 더 강합니다.

인밴드 시그널링이라는 원죄

근본 원인은 데이터와 제어가 같은 스트림에 섞여 있다는 것입니다. 5절에서 본 이스케이프 시퀀스가 바로 그거예요. 1970년대에 선이 하나뿐이라 어쩔 수 없었던 선택인데, 그 결과로 지금까지 구조화된 출력이 불가능합니다.

프로그램이 "나 지금 사용자 입력 기다리는 중"이라고 말할 방법이 없어요. 화면에 그려서 사람이 읽게 하는 것 말고는요.

보안 문제이기도 합니다. 악의적인 파일 이름에 이스케이프 시퀀스를 넣어두면 cat이나 ls가 그걸 그대로 단말에 뱉고, 단말이 해석해버립니다. 과거에 이걸로 명령을 주입하는 취약점들이 있었어요.

그래서 통합이 필요한 것

herdr integration install claude가 하는 일이 이겁니다 — 옆 채널을 만드는 것.

에이전트가 훅/플러그인을 통해 "나 지금 blocked이고 이유는 permission_request야"라고 직접 보고하게 합니다. 화면을 읽어서 추측하는 대신에요.

{ "session_id": "abc123", "state": "blocked",
  "reason": "permission_request", "source": "agent" }

블로그 글의 요구가 결국 이겁니다 — 터미널이 잃어버린 구조를 옆 채널로 돌려달라. 그래서 §5의 "라이프사이클 계약을 노출하라"가 단순한 벤더 불평이 아니라 40년짜리 구조적 문제에 대한 요구로 읽혀야 합니다.

이미 벌어지고 있는 일: OSC 133

흥미롭게도 터미널 생태계가 이미 같은 방향으로 움직이고 있습니다.

OSC 133은 셸이 터미널에게 "여기부터 프롬프트, 여기부터 사용자 입력, 여기부터 명령 출력, 이 명령의 종료 코드는 N"이라고 알려주는 시퀀스입니다. 시맨틱 프롬프트라고 불러요.

OSC 133 ; A ST     프롬프트 시작
OSC 133 ; B ST     명령 입력 시작
OSC 133 ; C ST     명령 실행 시작
OSC 133 ; D ; 0 ST 종료, exit code 0

이걸 쓰면 터미널이 "이전 명령으로 점프", "실패한 명령만 강조", "출력만 복사" 같은 걸 할 수 있습니다. 격자에서 역추론하지 않고 셸이 직접 알려주니까요.

wezterm이 이걸 지원합니다. VS Code, Kitty, Ghostty도요. 님 환경에서 셸 통합을 켜두면 이미 쓰고 계실 수도 있어요.

구조가 완전히 똑같습니다.

셸  → OSC 133 → 터미널        "이건 프롬프트, 이건 출력, 종료코드 N"
에이전트 → 훅  → Herdr        "나 blocked, 이유는 permission_request"

같은 문제의 두 사례입니다. 터미널에 구조를 되돌려주는 작업이 셸 층위에서는 이미 표준화가 진행됐고, 에이전트 층위에서는 지금 막 시작된 거예요. 블로그 글이 요구하는 게 결국 "에이전트판 OSC 133"입니다.

더 파볼 만한 것 — wezterm 셸 통합 설정하고 OSC 133 켜보기 / cat 이스케이프 시퀀스 취약점 사례 / Kitty의 확장 키보드 프로토콜(Ctrl+Shift 조합을 구분할 수 없던 문제)


8. bracketed paste — 기계가 사람인 척하는 문제

증상부터

tmux send-keys로 멀티라인 프롬프트를 에이전트 TUI에 넣으면 줄바꿈이 개별 턴으로 제출되거나 TUI가 씹습니다. 그런데 Herdr 문서는 이렇게 말하죠.

agent prompt는 텍스트와 인코딩된 Enter를 함께 제출하고 터미널의 실시간 bracketed-paste 모드를 존중합니다.

이게 뭔 소린가.

터미널은 붙여넣기를 구분할 수 없다

터미널은 붙여넣은 텍스트와 타이핑한 텍스트를 구분할 수 없습니다. 둘 다 그냥 바이트가 들어오는 거니까요.

그래서 개행이 든 텍스트를 붙이면 셸이 그걸 실행합니다. 웹페이지에서 복사한 명령에 끝에 개행이 딸려 있으면 붙여넣는 순간 실행돼요. 악의적으로는 이걸 이용해서 "복사하면 보이지 않는 명령이 딸려오는" 공격도 가능했습니다.

해법: 붙여넣기를 괄호로 감싸기

2000년대 중반 xterm이 도입한 게 bracketed paste입니다. 붙여넣기 구간을 마커로 감쌉니다.

ESC[200~   붙여넣은 텍스트   ESC[201~

프로그램이 이 모드를 켜두면(ESC[?2004h), 감싸인 내용은 "사람이 친 게 아니라 붙인 것" 으로 취급합니다. 그래서 개행이 있어도 실행하지 않고 그냥 텍스트로 넣죠.

# bash/zsh에서 확인
bind -V 2>/dev/null | grep -i bracketed     # bash
# zsh는 대개 기본 활성

멀티라인을 붙여넣었을 때 실행되지 않고 프롬프트에 그대로 들어가는 걸 경험하셨다면, 그게 이 기능입니다.

그래서 agent prompt가 하는 일

에이전트 TUI도 bracketed paste를 씁니다. 멀티라인 프롬프트를 받되 중간 개행으로 제출하지 않으려면 필수니까요.

그런데 이 모드는 켜졌다 꺼졌다 합니다. 에이전트가 입력 대기 중일 때와 아닐 때가 다르고요.

tmux send-keys는 이걸 모릅니다. 그냥 키 바이트를 쏟아붓죠. 그래서 개행이 턴 제출로 해석되거나, 모드가 안 맞아서 마커가 텍스트로 찍히거나 합니다.

agent prompt그 순간의 모드를 확인하고 거기 맞춰 인코딩합니다. 텍스트와 Enter를 원자적으로 제출한다는 게 이 뜻이에요.

회귀 구조

정리하면 이렇습니다.

사람이 친 것 vs 붙여넣은 것       → bracketed paste (2000년대)
사람이 친 것 vs 에이전트가 보낸 것 → agent prompt (2020년대)

"기계가 사람인 척 입력하는 문제"를 20년 전에 한 번 풀었고, 지금 에이전트 시대에 다시 풀고 있는 겁니다. 터미널이 입력 출처를 구분하지 못한다는 같은 한계에서 나온 같은 모양의 문제예요.

더 파볼 만한 것 — 붙여넣기 공격(pastejacking) 사례 / 왜 Ctrl+V 같은 리터럴 입력이 따로 필요한가


9. 왜 하필 유닉스가 살아남았나

기술적 우수성만으로는 설명이 안 됩니다. 여기엔 법이 개입해 있어요.

AT&T는 1956년 반독점 동의판결로 통신 외 사업이 금지돼 있었습니다. 그래서 유닉스를 상품으로 팔 수 없었어요. 대신 대학에 소스 코드를 명목상의 비용으로 배포했습니다.

결과가 이렇습니다.

  • 한 세대의 학생들이 커널 소스를 읽으며 자랐다 — 존 라이언스가 1976년에 V6 소스 전문에 주석을 단 책을 냈는데, OS 전체를 교재로 통째로 읽는 게 가능했습니다. V6 커널이 C 코드 약 9,000줄이었거든요 (참고로 지금 리눅스 커널은 3천만 줄이 넘습니다)
  • 버클리에서 BSD가 나왔다 — vi, csh, TCP/IP 스택, 소켓 API가 여기서 나옵니다
  • 관습이 확산됐다 — 학생들이 졸업해서 회사에 갈 때 유닉스 방식을 가져갔습니다

이게 없었으면 유닉스는 벨 연구소 내부 도구로 끝났을 겁니다. 지금 우리가 /dev/pts/3을 보는 건 기술사이자 반독점법의 결과이기도 합니다.

여담으로 자립(self-hosting)의 문제도 있었습니다. PDP-7에는 처음에 아무것도 없어서, GE-635에서 크로스 어셈블해 종이테이프에 뚫어서 들고 가 읽혔습니다. 어셈블러와 에디터가 PDP-7 위에서 돌기 시작한 순간부터 자기 기계에서 자기를 개발할 수 있게 됐고, 1973년에는 커널을 C로 다시 써서 이식성까지 얻었죠. 어셈블리로 남았으면 PDP-11과 함께 죽었을 겁니다.

더 파볼 만한 것 — Lions' Commentary(지금은 합법적으로 구할 수 있습니다) / BSD 소송과 리눅스 타이밍 / Plan 9 — 벨 연구소가 유닉스를 다시 만들려 한 시도


10. 남은 부채와, 다른 길들

지금까지가 "제약이 지혜를 낳았다"는 이야기였다면, 균형을 위해 반대편도 봐야 합니다. 명백한 부채도 많이 남았어요.

부채 목록

  • $TERM과 terminfo — 물리적 단말기가 사라진 지 40년인데 그대로. 널리 인정된 실패작입니다
  • 인밴드 시그널링 — 데이터와 제어가 한 스트림. 구조화된 출력이 불가능한 근본 원인이자 보안 이슈의 원천
  • 80컬럼 — IBM 천공 카드가 80열이었기 때문입니다. 코드 스타일 가이드의 80자 제한이 1928년 카드 규격에서 왔어요
  • ? 하나짜리 에러 메시지ed는 극단적 사례지만, 유닉스 도구 전반의 과묵함이 지금 기준으로는 불친절합니다
  • 텍스트 스트림의 한계ls | grep이 우아하지만, 구조화된 데이터를 다룰 때는 파싱 지옥이 됩니다. PowerShell이나 nushell이 객체 파이프라인으로 다른 답을 시도하는 이유죠

다른 길들

Herdr는 "터미널 안에 머문다" 를 선택했습니다. 그게 유일한 답은 아니에요.

  • Warp — 터미널을 블록 단위로 재구성. 명령과 출력을 구조화된 단위로 다룹니다
  • Arcan / Wave — 디스플레이 서버 층위에서 다시 생각하는 시도
  • 에디터 통합 — VS Code, Zed, Cursor는 아예 에이전트를 에디터 안에 넣었습니다. 터미널을 거치지 않죠
  • 웹 UI — 브라우저에서 에이전트를 관리하는 도구들

각 선택에는 트레이드오프가 있습니다. 터미널에 머무는 대가는 구조가 없다는 것(7절)이고, 얻는 것은 기존 도구가 전부 그대로 동작한다는 것입니다. nvim도, lazygit도, ssh도, 님이 이미 쓰는 모든 것이요.

님 환경처럼 CLI 도구 위에 워크플로가 쌓여 있으면 이 트레이드오프가 유리합니다. 반대로 에디터 중심으로 일하는 사람에게는 에디터 통합이 나을 수 있고요. Herdr가 옳아서가 아니라 님의 도구 스택과 맞아서 선택하는 것이라는 게 정확한 표현일 겁니다.

살아있는 화석 만져보기

stty -a
# speed 38400 baud;  ← 물리 시리얼이 없는데 baud rate가 있음

stty -icanon          # line discipline 끄기 (엔터 없이 한 글자씩 전달)
stty sane             # 되돌리기 (화면이 깨졌을 때도 이걸로 복구)

tty                   # 내 PTY
who                   # 다른 pts에 누가 있나 — 시분할의 잔재
echo $TERM            # 존재하지 않는 단말기의 이름

stty -a에 여전히 speed 38400 baud가 뜨는 걸 보는 게, 설명 열 줄보다 낫습니다.


11. 시분할의 반복

마지막으로 구도를 봅니다.

1970년대:  사람 여럿  →  기계 하나      (기계가 희소)
2000년대:  사람 하나  →  기계 여럿      (기계가 흔함)

시분할의 전제가 완전히 무너졌는데도 추상화는 살아남았습니다. 필요 없어졌는데 관성으로 남은 걸까요? 최근까지는 대체로 그랬습니다. 대부분의 개발자에게 pts 번호나 controlling terminal은 알 필요 없는 내부 사정이었어요.

그런데 구도가 다시 뒤집혔습니다.

  • 희소한 자원: 사람의 주의력과 판단
  • 여러 "사용자": 워크트리마다 도는 에이전트들
  • 각자 자기 단말(pane)을 갖고, 상태를 갖고, 승인을 요청함
  • 한 명의 감독자가 여러 세션을 오가며 순회

Herdr가 하는 일을 이렇게 표현할 수 있습니다 — 에이전트를 위한 시분할 시스템에 오퍼레이터 콘솔을 달아준 것. 사이드바가 사실상 옛 기계실 관리자가 보던 상태판이고, blocked는 "3번 단말이 오퍼레이터 개입을 기다림"인 셈입니다.

doneidle을 구분하는 이유도 여기서 나옵니다. 감독자의 주의가 희소 자원이니, "처리됐는가"와 "감독자가 확인했는가"를 따로 추적해야 하거든요. 이건 새 개념이 아니라 오퍼레이터 워크플로에 원래 있던 구분입니다.

50년 전 배선도가 살아남은 게 우연이 아니라, "희소한 감독자 + 다수의 실행 주체"라는 구도가 다시 나타났기 때문이라고 보는 게 정확할 것 같습니다.


부록: 한 장짜리 계보

텔레타이프 (초당 10글자, 종이)
    ├─ 짧은 명령어 (ls, cp, rm, ed)
    ├─ 조용한 도구 (성공하면 아무 말 없음)
    ├─ g/re/p → grep
    ├─ /dev/tty (리다이렉트를 뚫는 통로)
    └─ roff → nroff/troff → man 페이지

비디오 단말 (VT100, 1978)
    ├─ 이스케이프 시퀀스 (ANSI X3.64)
    ├─ termcap/terminfo/$TERM  ← 부채
    ├─ alternate screen        ← Herdr가 긴 출력을 못 읽는 이유
    └─ vi (ed → ex → visual mode)

전화선 끊김
    ├─ SIGHUP → nohup, disown
    ├─ session / process group / controlling terminal
    └─ GNU screen (1987) → tmux (2007) → Herdr (2026)
           "master를 서버가 쥐면 클라이언트가 끊겨도 된다"

인밴드 시그널링의 한계
    ├─ 터미널에 구조가 없음 → 화면 감지의 취약함
    ├─ bracketed paste (붙여넣기 vs 타이핑)
    ├─ OSC 133 (셸 → 터미널 시맨틱)
    └─ 에이전트 라이프사이클 계약  ← 지금 논의 중인 것

시분할
    ├─ 희소한 기계 + 다수의 사람 (1970s)
    └─ 희소한 주의력 + 다수의 에이전트 (2020s)

부록: 이 문서에서 나온 명령 모음

tty                      # 내 PTY
ls -l $(tty)             # 장치 파일 확인 (major, minor 번호)
stty -a                  # 터미널 설정 — baud rate가 아직 있음
stty sane                # 화면이 깨졌을 때 복구
echo $TERM               # 단말 종류
infocmp | head           # 이 단말이 뭘 지원한다고 주장하는지
who                      # 로그인한 다른 단말들
ps -o pid,pgid,sid,tty,comm
printf '\033[31mRED\033[0m\n'
man ls                   # 50년 된 마크업이 렌더링되는 것
python3 -c "import sys; print(sys.stdout.isatty())"
python3 -c "import sys; print(sys.stdout.isatty())" | cat