SLOPSHOPPER

kasaterm-bridge

claude 칸의 상태·승인 요청·백그라운드·도구 활동·대화를 kasaterm 에 알린다

newguardtoastpromptprocessnetwork
A shopper browsing a rack in a slop shop
README

<img src="assets/AppIcon.png" width="120" alt="kasaterm" />

kasaterm

Rust로 바닥부터 만든 크로스플랫폼 GPU 터미널.

셀 렌더러 · 한글 IME · PTY를 기성 라이브러리 없이 자체 crate로 구현했고,<br/> 그 위에 여러 Claude를 학생처럼 거느리는 GUI를 얹었다.

데모 · 강점 · crate · 기계를 가로지른다 · 설치 · 단축키 · 구조

Release License Platform Built with Rust Renderer GitHub stars GitHub Sponsors


데모

<img src="assets/shot-terminal.png" width="820" alt="kasaterm — GUI 버튼·드래그로 나눈 멀티페인. 한글 커밋 로그와 색재현이 그대로." /> <sub>GUI 버튼·드래그로 나눈 멀티페인. tmux prefix 키 없이 분할하고, 한글·색·box-drawing이 자체 렌더러로 그려진다.</sub>


이게 뭐야

자체 제작 GUI 터미널이다. tmux를 prefix 키 대신 GUI 버튼·드래그·자연어로 다루는 네이티브 Rust 앱이고, 렌더러·한글 IME·PTY까지 기성 터미널 라이브러리에 기대지 않고 전부 직접 만들었다.

두 축으로 읽으면 된다:

  • 아래 — 터미널 엔진. wgpu 셀 렌더러, 두벌식 한글 IME, 크로스플랫폼 PTY를 각각 독립 crate로 깎았다. 터미널을 만들려는 사람이 부품만 가져다 쓸 수 있게 설계했다.
  • 위 — AI 오케스트레이션. 그 엔진 위에서, pane마다 도는 Claude의 작업이 BA GUI(아로나 모드)로 실시간으로 보인다. 로그를 읽는 게 아니라 작업을 지켜본다.
  • 밖 — 기계를 가로지른다. pane이 이 컴퓨터에 묶여 있지 않다. 도는 세션을 다른 기계로 이사시키고, 그 화면을 원래 창에 거울로 두고, 폰 브라우저로 같은 pane을 이어서 본다.

강점 — 전부 자체 구현했다

기성 라이브러리를 붙인 게 아니라, 터미널의 핵심 부품을 바닥부터 만들었다.

무엇crate
GPU 셀 렌더swash atlas에 글리프를 한 번 굽고 셀당 인스턴스 1개로 그린다. box-drawing은 wgpu quad, CJK·이모지 fallback 내장kasa-cells
한글 IMEOS IME에 의존하지 않는 두벌식 입력 오토마타. 복합 종성까지 자체 조합kasa-ime
크로스플랫폼 PTYportable-pty + alacritty_terminal. macOS·Linux BSD PTY와 Windows ConPTY가 동일 코드 경로kasa-pty
색재현shader sRGB→DisplayP3 변환 + root CAMetalLayer. 터미널 색이 디자인 의도대로 (sugarloaf/ghostty 동급)kasa-cells

크로스플랫폼

macOS·Windows·Linux를 같은 코드로 굴린다. PTY는 portable-pty로 추상화해 Windows에서는 ConPTY, 그 외에서는 BSD PTY로 자동 분기한다 — 플랫폼별 백엔드 분기 없이 동일 경로. macOS .app, Windows .msi 번들을 빌드 스크립트로 굽는다.

재사용 가능한 crate

워크스페이스가 곧 부품 카탈로그다. 각 crate는 kasaterm 없이도 독립적으로 쓸 수 있게 경계를 잡았다 — 특히 kasa-cells는 프레임워크 중립이라 alacritty_terminal·wezterm-term 같은 터미널 상태머신과 짝지어 다른 터미널을 만드는 데 그대로 가져다 쓸 수 있다.

crate한 줄독립 사용
kasa-cells프레임워크 중립 GPU 셀 렌더러 (wgpu). swash atlas·sRGB→P3·box-drawing·Nerd 폰트 번들터미널/그리드 UI 제작용
kasa-ptyPTY + alacritty_terminal 백엔드. 크로스플랫폼(ConPTY 포함)헤드리스 PTY 호스트
kasa-ime두벌식 한글 입력 오토마타. OS IME 비의존한글 입력이 필요한 Rust 앱
kasa-socketcmux 호환 Unix-socket JSON-RPC 서버. kasaterm-cli 포함pane 제어 프로토콜
kasa-bridgetmux control-mode(-C) 브리지. GUI 비의존, 이벤트·화면 채널만 넘긴다tmux를 붙이는 다른 UI
kasa-mcp원격 세션·기계 명부·폰 관문·보드의 HTTP 층폰·다른 기기·웹 pane 연동
app/kasaterm메인 바이너리 — winit+wgpu 윈도우, chrome UI, 입력·단축키 라우팅—

그 위 — 여러 Claude를 거느린다

엔진이 안정될수록 그 위에 쌓는 게 본 게임이다. pane마다 Claude Code를 띄우고, 각 학생(pane)이 무슨 작업을 하는지 BA GUI로 한눈에 본다.

<img src="assets/shot-arona.png" width="780" alt="kasaterm BA GUI — 여러 Claude의 작업이 학생별 채팅·작업 트리로 실시간 표시" /> <sub>왼쪽 교실에 학생(pane)들이, 가운데 각 학생의 대화·작업이, 오른쪽 Command Center에 현재 작업이 실시간으로.</sub>

다른 터미널과 다른 점:

  • 작업이 굴러가는 걸 본다 — pane에서 Claude가 하는 일이 채팅·작업 트리로 실시간 표시된다.
  • pane끼리 연동된다 — pane이 격리된 창이 아니라, 에이전트가 pane을 넘나들며 협업하는 하나의 작업 공간이다. 방이 달라도 서로 말을 걸고, 닫힌 pane에 일을 시키는 건 보내기 직전에 막힌다.
  • 창 전체가 한눈에 — 사이드바 배치도가 어느 칸에 누가 앉아 있는지, 무엇이 얼마나 도는 중인지, 손이 필요한 칸은 어디인지 그린다. 안 볼 pane은 죽이지 않고 치워 둔다.
  • 계정을 갈아끼운다 — 상태줄에 claude·codex 계정 슬롯이 모여 있고, 전환하면 도는 pane도 새 계정으로 되띄운다. 5시간·7일 한도와 무엇이 그걸 잡아먹는지가 하단바에 상시 표시된다.
  • 확장을 한 화면에서 — MCP·플러그인·에이전트·커맨드·훅을 하네스별·스코프별로 보고 거기서 바로 켜고 끈다.

claude를 켜면 화면에 학생이 산다

pane에서 claude를 실행하면 그 pane에 블루 아카이브 학생 한 명이 배정된다 — 이름·테두리색·프로필이 전부 그 학생으로 맞춰지고, 창 전체에서 겹치지 않게 자동으로 고른다. /rename 미도리처럼 이름을 바꾸면 원하는 학생으로 갈아끼운다. (기본 로스터 12명: 아로나·프라나·미도리·모모이·유즈·아리스·유우카·시로코·호시노·코하루·히마리·아루.)

로스터는 갈아끼울 수 있다. 테마 팩 하나가 캐릭터 세트 하나다 — 폴더에 명부(characters.json)·그림·색 프리셋을 넣어 두면 그게 통째로 로스터가 된다. 설정 창에 zip을 떨어뜨려 가져오고, 여러 팩을 가로질러 좋아하는 캐릭터만 골라 쓰는 풀도 만든다. 캐릭터마다 모델·성격·이름을 따로 지정할 수 있고, 대화를 끊지 않고 도중에 바꿔도 말투까지 따라온다.

그리고 kasaterm은 Claude Code가 그리는 터미널 화면 자체를 렌더 단계에서 읽어, 그 위에 배정된 학생을 그린다. 로그를 파싱하거나 별도로 통합한 게 아니라 — 화면만 보고 동작한다:

  • 시작 배너 → Claude Code의 Clawd 블록아트를 감지해 학생 도트 idle 애니메이션으로 교체
  • statusline → 모델·컨텍스트 옆에 학생 프로필(bust)을 2행으로
  • 입력창 위 → 대기 중엔 학생이 전신으로 서서 숨쉬고(idle 애니), effort 칩이 뜨면 옆으로 비켜선다
  • 작업 중 → 스피너 자리에서 학생이 걸어다닌다(walk)

로그를 읽는 게 아니라 학생이 옆에서 같이 일하는 것처럼 보인다.

<img src="assets/shot-sprite.png" width="820" alt="claude 로 대화하는 중 — 배정된 아루가 시작 배너·작업 스피너 옆·statusline 에 도트로 나타난다" /> <sub><code>claude</code> 로 대화하는 중 — 배정된 <b>아루</b>가 <b>시작 배너</b>(좌상) · <b>작업 스피너 옆 전신</b>(좌하) · <b>statusline 프로필</b>(최하단)에 동시에 나타난다. 작업 중일 땐 effort 칩을 피해 옆으로 비켜선다.</sub>

한 모노레포에 세 층이 쌓여 있고, 아래층이 위층을 떠받친다:

층코드네임역할상태
① 엔진kasaterm터미널 — wgpu 셀 렌더 · PTY · 한글 IME · multipane거의 안정
② 작업환경kasaspace파일트리 · git 관리 · pane 간 에이전트 연결진행 중
③ 오케스트레이션blueclaudearchive여러 Claude를 학생처럼 거느리는 하네스 GUI (아로나 모드)무게중심

기계를 가로지른다

pane이 한 대의 컴퓨터에 묶여 있지 않다. 노트북에서 띄운 학생을 데스크톱·서버로 옮기고, 그 화면은 원래 창에 그대로 남기고, 폰에서 같은 pane을 이어서 본다.

무엇
이사(migrate)도는 claude·codex 세션을 다른 기계로 통째로 옮긴다. 대화·모델·작업 경로가 따라가고, 커밋 안 한 변경과 안 올린 커밋까지 떠서 도착지에 재현한다
거울 pane옮긴 뒤에도 원래 창에 그 화면이 남는다. 거울 창을 줄여도 원본 기계의 화면 크기는 안 변한다 — 글자만 작아진다
세션 소통기계들이 중계소에 스스로 등록한다. 다른 기계에서 도는 세션이 내 목록에 뜨고, 메시지 한 통이면 거기까지 배달된다. 기계가 죽으면 1분 안에 목록에서 빠진다
폰 웹터미널앱이 켜지면 관문에 붙어 자기 주소를 하나 받는다. 폰에서 그 주소를 열면 방·학생 목록이 나오고, 고르면 그 pane이 그대로 뜬다. 끊기면 화면이 보일 때 저절로 재접속

기계를 명부에 적어 두면 원격 탭에서 방별로 학생을 보고, 학생 줄을 누르면 그 화면이 포커스된 pane의 탭으로 열리고(거울), 기계 하나의 학생 전부를 방 단위로 거울로 펼치고, 「화면 보기」로 그 기계의 화면공유를 연다. Info 탭 「다른 기계」 줄이 그 기계의 학생 수·기다림·거울 수를 요약한다. 원격 pane은 몸통에 색 리본이 붙어 헤더를 안 봐도 갈린다.


설치 & 실행

받아서 쓰기

최신 릴리스에서 받는다.

  • macOS — .dmg를 열고 kasaterm을 Applications로 드래그. 처음 한 번만 우클릭 → 열기(직접 서명한 앱이라 macOS가 한 번 확인받는다). 첫 실행에서 화면 녹화·접근 권한을 물으면 허용한다.
  • Windows — .msi 실행. SmartScreen 경고가 뜨면 「추가 정보 → 실행」.

앱 안에서 자동 업데이트를 받는다(macOS는 Sparkle, Windows는 WinSparkle — 릴리스마다 서명된 appcast가 붙는다).

소스에서 빌드

# 소스 받기
git clone https://github.com/2rami/kasaterm.git
cd kasaterm

# 개발 빌드
cargo run -p kasaterm

# 체감(스크롤·입력 지연) 테스트는 반드시 release — 디버그는 원래 버벅임
cargo run --release -p kasaterm

macOS .app은 scripts/build-app.sh, Windows .msi와 portable ZIP은 scripts/windows/package.ps1로 빌드한다. Windows 패키징은 완성된 MSI를 다시 추출해 앱·CLI·아로나 UI·학생 로스터·협업 훅의 누락까지 검사한다. 앱을 실행하면 pane 제어 CLI(kasaterm-cli)를 바로 쓸 수 있다.

폴더만 손으로 바꾸면 Claude Code 대화와 연결 worktree가 이전 경로를 계속 가리킨다. 먼저 kasaterm·Claude Code·Codex를 모두 정상 종료하고, 저장소의 바깥 폴더에서 이전 도구를 실행한다. 첫 명령은 바뀔 항목만 보여주는 dry-run이다.

cd /path/to/parent
./tmuxify/scripts/rename-repo-to-kasaterm.sh \
  --source "$PWD/tmuxify" \
  --target "$PWD/kasaterm"

# dry-run 내용을 확인한 뒤 실제 적용
./tmuxify/scripts/rename-repo-to-kasaterm.sh \
  --source "$PWD/tmuxify" \
  --target "$PWD/kasaterm" \
  --apply

적용 시 설정 원본과 Git 연결 정보는 ~/.config/kasaterm/migrations/ 아래에 백업된다. 스크립트는 연결 worktree를 복구·검증하고, 이전 경로를 가리키는 영구 symlink는 만들지 않는다.

Claude Code 플러그인 (kasapane 스킬)

멀티 pane 제어·협업·긴 잡 사이클·UI 자체검증 워크플로우를 Claude Code 스킬로 묶었다:

claude plugin marketplace add 2rami/kasaterm
claude plugin install kasapane@kasaterm

설치 후 /kasapane으로 호출한다. 스킬이 쓰는 kasaterm-cli는 앱 빌드에 내장돼 있다.


단축키

macOS는 Cmd, Windows/Linux는 Ctrl+Shift를 "호스트 modifier"로 쓴다 (Ctrl+letter는 셸로 흘려보내기 위함). 폰트 zoom만 Windows/Linux에서 Ctrl 단독.

pane 조작

동작macOSWindows / Linux
가로 분할 (위아래로 쌓기)Cmd + DCtrl + Shift + D
세로 분할 (좌우로 나누기)Cmd + Shift + D 또는 Cmd + ECtrl + Shift + E
포커스된 pane 닫기Cmd + WCtrl + Shift + W
pane 포커스 순환Cmd + [ / Cmd + ]Ctrl + Shift + [ / ]
방향 쪽 pane으로 포커스 이동Cmd + Option + 방향키Ctrl + Shift + Alt + 방향키
두 pane 위치 맞바꾸기(swap)Cmd + Option + Shift + 방향키(동일 패턴)

크기 / 폰트

동작macOSWindows / Linux
전체 UI 확대 / 축소 / 리셋Cmd + = / Cmd + - / Cmd + 0Ctrl + = / Ctrl + - / Ctrl + 0
포커스된 pane만 폰트 확대 / 축소 / 리셋Cmd + Shift + = / Cmd + Shift + - / Cmd + Shift + 0Ctrl + Alt + = / Ctrl + Alt + - / Ctrl + Alt + 0

pane 사이 비율 조절은 경계선(divider) 마우스 드래그, pane을 끌어 합치거나 나누는 건 drag → merge/split 존으로 한다 (키보드 단축키 없음).

윈도우 / 셸 입력 보조

동작키
새 윈도우 (PTY 백엔드)Cmd + T
윈도우 1~9 전환Cmd + 1 ~ Cmd + 9
자동완성 suggestion 수락→ / End / Ctrl + E
suggestion 단어 단위 수락Alt(Option) + F
단어 단위 삭제Alt(Option) + Backspace

구조

워크스페이스 멤버는 강점 — 재사용 가능한 crate 표 참고. spikes/*는 iced/egui/gpui/warpui 등 채택 안 된 GUI 프레임워크 PoC다.

렌더러 / 환경 변수

기본 렌더러는 cell-renderer(gpu.rs) + P3. 주요 env 토글:

변수효과
KASATERM_P3_ROOT=0P3 root-layer 경로 끄고 옛 sRGB sublayer 폴백
KASATERM_TEXT_GAMMA / _CONTRAST / _COLOR_SAT텍스트 감마·대비·채도 노브
KASATERM_AUTOSPLIT / _MSN초 후 자동 분할 ("vh" 등, 헤드리스 검증용)
KASATERM_AUTOCAPTURE_MS / _PATHN초 후 자동 스크린샷 (자체 테스트용)
KASATERM_AUTOSEND / _MSN초 후 키 자동 전송 (자체 테스트용)

모델이 쓰는 도구

pane 목록·이름·분할·전송 같은 조작은 전부 kasaterm-cli가 맡는다(앱 빌드에 내장). 모델이 셸에서 그대로 부르는 편이 왕복이 적고, 도구 설명이 매 요청 실리지도 않는다 — kasaterm-cli board로 남이 뭘 하는지 보고, kasaterm-cli tell로 말을 건다. 예전의 kasaspace MCP 도구는 이것과 겹쳐 걷었고, 앱이 부팅할 때 옛 등록 항목을 AI 클라이언트 설정에서 지운다.


로드맵

세 층이 같이 진화 중이다. 아래층이 안정될수록 위층을 더 단단히 떠받친다.

층항목상태
① 엔진wgpu 셀 렌더 · P3 색재현안정
① 엔진두벌식 한글 IME (OS 비의존)안정
① 엔진크로스플랫폼 PTY (macOS · Windows · Linux)안정
① 엔진claude --resume 세션 복원안정
② 작업환경파일트리 · git 패널진행 중
② 작업환경pane 간 에이전트 연결안정
② 작업환경기계 간 세션 이사 · 거울 pane진행 중
② 작업환경폰 웹터미널 (관문 주소)진행 중
③ 오케스트레이션BA GUI — 작업 실시간 시각화진행 중
③ 오케스트레이션여러 Claude 협업 (아로나 모드)안정
③ 오케스트레이션테마 팩 — 캐릭터 세트 교체안정
③ 오케스트레이션claude · codex 계정 슬롯 전환진행 중

왜 만들었나

tmux로 Claude Code 팀모드를 굴리다 시작됐다. 여러 에이전트를 한 화면에 띄워 쓰다 보니, "작업할 때만이 아니라 평소에도 에이전트끼리 소통하면 어떨까" 싶었다.

마침 불편한 게 겹쳤다. ghostty 같은 GPU 터미널은 쾌적한데 윈도우엔 마땅한 게 없었고, 터미널 안에서 여러 에이전트가 무슨 작업을 하는지는 로그를 헤집어야 보였다. 그래서 세 가지를 한 번에 풀기로 했다 — 플랫폼에 묶이지 않는 GPU 터미널, 그 위에 올린 나만의 하네스, 그리고 작업이 굴러가는 걸 한눈에 보여주는 UI.

기성 라이브러리에 기대지 않고 직접 만들고 싶었다. 디자이너로 일하다 개발에 입문한 터라, 터미널이 정보를 보여주는 방식 자체가 늘 답답했던 것도 있다. GPU 셀 렌더러(P3 색재현), 두벌식 한글 IME(OS IME 비의존), 크로스플랫폼 PTY까지 전부 자체 구현했고, 그 과정에서 깎인 부품들을 누구나 가져다 쓸 수 있는 crate로 남겼다. 결과물보다 만들면서 배운 게 더 컸다.

무료로 공개한다. 누군가에게 쓸모가 되거나, 같은 길을 걷는 사람에게 참고가 되면 충분하다.


후원

혼자 만드는 프로젝트입니다. 쓸모가 있었다면 후원으로 응원해주세요.

GitHub Sponsors

라이선스

MIT

Source 2 files
hooks/register.ts 340 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  activityLabel,
5  askKey,
6  clip,
7  launchedTask,
8  mergeTasks,
9  permissionPreview,
10  running,
11  stopTasks,
12  taskNotification,
13  usageEvent,
14} from './bridge'
15import type { BridgeEvent, Task } from './bridge'
16
17const MOD_VERSION = '0.1.0'
18const QUEUE_CAP = 500
19// 긴 폴링 한 번의 한도(앱이 이보다 오래 쥐지 않는다). 승인 요청은 앱의 만료(10분)를 넘겨 다시 열지 않는다.
20const POLL_WAIT_MS = 20000
21const PERMISSION_ROUNDS = 26
22const SAY_CAP = 2000
23const RESULT_CAP = 600
24// 보낸 쪽지가 버려졌다는 한 줄 — 칸을 보는 사람이 놓치지 않을 만큼 둔다. 포인터를 올리면 더 머문다.
25const NOTICE_TOAST_MS = 20000
26
27type Ask = { id: string; tool: string; agent?: string }
28
29// 모듈 변수는 다시 실릴 때 처음으로 돌아간다 — 정본은 앱이 쥐고, 여기는 보낼 줄과 지금 일의 짐작만 쥔다.
30const io = {
31  base: '',
32  surface: '',
33  session: '',
34  pid: 0,
35  queue: [] as BridgeEvent[],
36  flushing: false,
37  turnOpen: false,
38  asks: new Map<string, Ask>(),
39  permissions: new Set<string>(),
40  question: '',
41  others: [] as Task[],
42  lastTasks: '',
43  listening: false,
44  // 다음 턴을 연 프롬프트가 사람·다른 세션의 말이 아니면(백그라운드 끝남 알림) 활동에 「시킴」으로 안 싣는다.
45  quiet: false,
46}
47
48function send($: EngineInterface, event: Omit<BridgeEvent, 'at'>) {
49  if (!io.base) return
50  if (io.queue.length >= QUEUE_CAP) io.queue.shift()
51  io.queue.push({ ...event, at: Date.now() } as BridgeEvent)
52  void flush($)
53}
54
55// 한 번에 하나만 보낸다 — 차례가 곧 뜻이다(턴 시작 → 도구 → 턴 끝).
56async function flush($: EngineInterface) {
57  if (io.flushing) return
58  io.flushing = true
59  try {
60    while (io.queue.length > 0 && io.base) {
61      const events = io.queue.splice(0, io.queue.length)
62      try {
63        await post($, '/claude-mod/event', { v: 1, surface: io.surface, session: io.session, events })
64      } catch {
65        // 앱이 없거나 재시작 중이다 — 보낸 줄은 버린다. 다시 뜬 앱은 다음 인사로 이 칸을 다시 안다.
66      }
67    }
68  } finally {
69    io.flushing = false
70  }
71}
72
73async function post($: EngineInterface, path: string, body: unknown) {
74  return $.http.fetch(`${io.base}${path}`, {
75    method: 'POST',
76    headers: { 'content-type': 'application/json' },
77    body: JSON.stringify(body),
78  })
79}
80
81// claude 프로세스의 pid — 앱은 이것이 그 칸에 지금 도는 claude 와 같을 때만 이 mod 를 정본으로 친다.
82async function claudePid($: EngineInterface): Promise<number> {
83  try {
84    const run = await $.process.run(['/bin/sh', '-c', 'echo $PPID'], { timeoutMs: 5000 })
85    const pid = Number(run.stdout.trim())
86    return Number.isInteger(pid) && pid > 1 ? pid : 0
87  } catch {
88    return 0
89  }
90}
91
92async function hello($: EngineInterface) {
93  if (!io.base) return
94  io.session = await $.session.id()
95  const version = await $.session.version()
96  send($, { kind: 'hello', mod: MOD_VERSION, claude: version.version, pid: io.pid, cwd: await $.session.cwd() })
97  await measure($)
98  await reportTasks($)
99}
100
101async function boot($: EngineInterface) {
102  const port = await $.env.get('KASASPACE_MCP_PORT')
103  const surface = await $.env.get('KASATERM_PANE_ID')
104  if (!port || !surface || !/^\d+$/.test(port)) return
105  io.base = `http://127.0.0.1:${port}`
106  io.surface = surface
107  // 이 claude 가 낳는 settings 훅·명령이 「mod 가 알린다」를 안다 — 같은 일을 하던 옛 훅이 쉰다.
108  await $.env.set('KASATERM_MOD_BRIDGE', '1')
109  io.pid = await claudePid($)
110  await hello($)
111  $.clock.every(1000, () => void listen($))
112}
113
114async function measure($: EngineInterface) {
115  const usage = await $.session.usage()
116  send($, usageEvent(usage, Date.now()))
117}
118
119async function reportTasks($: EngineInterface) {
120  const agents = await $.agent.list()
121  const tasks = running(mergeTasks(agents, io.others))
122  const key = JSON.stringify(tasks)
123  if (key === io.lastTasks) return
124  io.lastTasks = key
125  send($, { kind: 'background', tasks })
126}
127
128// 보낸 칸 알림 — 앱이 맡긴 한 줄(보낸 쪽지가 버려짐)을 토스트로만 띄운다. 프롬프트로 넣으면 이 칸의 턴을
129// 깨워 일을 끊는다 — 토스트는 대화에도 모델에도 안 들어간다. 그래서 일하는 중에도 받는다.
130async function listen($: EngineInterface) {
131  if (io.listening || !io.base) return
132  io.listening = true
133  try {
134    const query = `surface=${encodeURIComponent(io.surface)}&wait_ms=${POLL_WAIT_MS}`
135    const res = await $.http.fetch(`${io.base}/claude-mod/notices?${query}`)
136    if (!res.ok) return
137    const { notices } = JSON.parse(res.text) as { notices?: string[] }
138    for (const text of notices ?? []) $.ui.toast(text, { timeoutMs: NOTICE_TOAST_MS })
139  } catch {
140    // 앱이 없으면 다음 박자에 다시 연다.
141  } finally {
142    io.listening = false
143  }
144}
145
146async function askRemote(
147  $: EngineInterface,
148  request: Record<string, unknown>,
149): Promise<{ decision: 'allow' | 'deny'; message: string } | null> {
150  for (let round = 0; round < PERMISSION_ROUNDS; round++) {
151    const res = await post($, '/claude-mod/permission', { v: 1, surface: io.surface, session: io.session, request })
152    if (!res.ok) return null
153    const answer = JSON.parse(res.text) as { decision?: string; message?: string; pending?: boolean }
154    if (answer.decision === 'allow' || answer.decision === 'deny') {
155      return { decision: answer.decision, message: answer.message ?? '' }
156    }
157    if (!answer.pending) return null
158  }
159  return null
160}
161
162export const register: Register = on => {
163  on('session.start', async ($, e, next) => {
164    const result = await next(e)
165    await boot($)
166    return result
167  })
168
169  // /clear·/resume 는 같은 프로세스에서 세션 id 만 바꾸고 session.start 를 다시 안 낸다.
170  on('session.end', async ($, e, next) => {
171    const result = await next(e)
172    if (e.reason === 'clear' || e.reason === 'resume') {
173      io.turnOpen = false
174      io.permissions.clear()
175      io.question = ''
176      io.others = []
177      $.clock.after(300, () => void hello($))
178    } else {
179      send($, { kind: 'bye', reason: e.reason })
180      await flush($)
181    }
182    return result
183  })
184
185  on('turn.start', async ($, e, next) => {
186    io.turnOpen = true
187    send($, { kind: 'turn', phase: 'start', turn: e.turnId, text: io.quiet ? '' : clip(e.text, SAY_CAP) })
188    io.quiet = false
189    return next(e)
190  })
191
192  on('turn.complete', async ($, e, next) => {
193    const result = await next(e)
194    if (e.agentId === undefined) {
195      io.turnOpen = false
196      io.question = ''
197      send($, { kind: 'turn', phase: 'end', turn: e.turnId, reason: e.reason, answer: clip(result.text, SAY_CAP) })
198    }
199    void reportTasks($)
200    return result
201  })
202
203  on('session.compact', async ($, e, next) => {
204    if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
205    send($, { kind: 'compact', phase: 'start', trigger: e.trigger })
206    try {
207      return await next(e)
208    } finally {
209      send($, { kind: 'compact', phase: 'end', trigger: e.trigger })
210    }
211  })
212
213  // 압축의 끝을 한 번 더 — 다시 실린 mod 는 그 앞 압축의 finally 를 잃는다.
214  on('classic.PostCompact', async ($, e, next) => {
215    send($, { kind: 'compact', phase: 'end', trigger: e.trigger })
216    return next(e)
217  })
218
219  on('session.measure', async ($, e, next) => {
220    send($, usageEvent(e, Date.now()))
221    return next(e)
222  })
223
224  on('tool.check', async ($, e, next) => {
225    const verdict = await next(e)
226    if (verdict.decision === 'ask' && e.tool_use_id) {
227      io.asks.set(askKey(e.tool, e.input), { id: e.tool_use_id, tool: e.tool })
228    }
229    return verdict
230  })
231
232  // 엔진의 승인 창은 이 훅과 함께 뜬다 — 여기서 기다리는 동안에도 자리의 사람은 그 창으로 답한다. 원격 결정이
233  // 먼저 오면 그것으로, 아니면 엔진 몫으로 돌려준다(사람이 먼저 답하면 엔진이 이 훅의 늦은 답을 버린다).
234  on('classic.PermissionRequest', async ($, e, next) => {
235    const below = await next(e)
236    if (below.decision || below.block || !io.base) return below
237    const key = askKey(e.tool_name, e.tool_input)
238    const ask = io.asks.get(key)
239    io.asks.delete(key)
240    const id = ask?.id ?? `perm-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
241    io.permissions.add(id)
242    const request = {
243      id,
244      tool: e.tool_name,
245      input: e.tool_input,
246      preview: permissionPreview(e.tool_name, e.tool_input),
247      cwd: e.cwd,
248      agent: ask?.agent ?? null,
249      suggestions: e.permission_suggestions ?? [],
250      created_at_ms: Date.now(),
251    }
252    try {
253      const remote = await askRemote($, request)
254      if (remote?.decision === 'allow') return { ...below, decision: { behavior: 'allow' } }
255      if (remote?.decision === 'deny') {
256        return { ...below, decision: { behavior: 'deny', message: remote.message || '원격에서 거절했어요' } }
257      }
258    } catch {
259      // 앱에 못 닿으면 엔진 창 혼자 묻는다.
260    }
261    return below
262  })
263
264  on('tool.call', async ($, e, next) => {
265    const id = e.tool_use_id ?? ''
266    const main = e.agentId === undefined
267    send($, { kind: 'tool', phase: 'start', id, tool: e.tool, label: activityLabel(e.tool, e), agent: e.agentId ?? null })
268    if (main && e.tool === 'AskUserQuestion') {
269      io.question = id
270      send($, { kind: 'question', phase: 'start', id })
271    }
272    let failed = true
273    let text = ''
274    try {
275      const result = await next(e)
276      failed = 'deny' in result && result.deny !== undefined ? true : result.isError === true
277      text = clip(result.deny ?? result.text ?? '', RESULT_CAP)
278      if (!failed) {
279        const task = launchedTask(e.tool, e, result.result)
280        if (task) {
281          io.others = [...io.others.filter(t => t.id !== task.id), task]
282          void reportTasks($)
283        }
284      }
285      // Task·KillShell 은 다른 판의 이름이다 — 이 판의 도구 표에 없어 글자로 견준다.
286      if (['Agent', 'Task', 'TaskStop', 'KillShell'].includes(String(e.tool))) void reportTasks($)
287      return result
288    } finally {
289      if (io.permissions.delete(id)) {
290        send($, { kind: 'permission', phase: 'resolved', id, outcome: failed ? 'denied' : 'ran' })
291      }
292      if (main && io.question === id) {
293        io.question = ''
294        send($, { kind: 'question', phase: 'end', id })
295      }
296      send($, { kind: 'tool', phase: 'end', id, tool: e.tool, error: failed, text, agent: e.agentId ?? null })
297    }
298  })
299
300  // Enter 순간이다 — turn.start 는 UserPromptSubmit 훅이 다 돈 뒤(실측 2초 남짓)라, 쉬던 칸은 여기서 먼저 「일함」을
301  // 알린다. 훅이 프롬프트를 버리면 되돌린다. 백그라운드 끝남 알림이면 그 작업을 목록에서 내린다.
302  on('prompt.submit', async ($, e, next) => {
303    if (e.origin.kind === 'task-notification') {
304      const done = taskNotification(e.text)
305      if (done) {
306        io.others = io.others.filter(t => t.id !== done.id)
307        void reportTasks($)
308      }
309    }
310    const early = e.turnId === undefined && !io.turnOpen
311    if (early) {
312      io.turnOpen = true
313      io.quiet = e.origin.kind === 'task-notification'
314      send($, { kind: 'turn', phase: 'start', turn: '', text: io.quiet ? '' : clip(e.text, SAY_CAP) })
315    }
316    const result = await next(e)
317    if (early && 'drop' in result && result.drop !== undefined) {
318      io.turnOpen = false
319      send($, { kind: 'turn', phase: 'end', turn: '', reason: 'dropped' })
320    }
321    return result
322  })
323
324  // 턴이 멈출 때 엔진이 주는 지금 도는 작업 목록이 정본이다 — 놓친 끝남을 여기서 바로잡는다.
325  on('classic.Stop', async ($, e, next) => {
326    io.others = stopTasks(e.background_tasks).filter(t => t.type !== 'subagent')
327    void reportTasks($)
328    return next(e)
329  })
330
331  // 대화 행이 쌓였다 — 대화 보기(폰·데스크톱)가 기록 파일을 다시 읽을 때다. 내용은 파일이 정본이라 싣지 않는다.
332  on('session.append', async ($, e, next) => {
333    const stored = await next(e)
334    if (e.agentId === undefined && io.base && e.message.type !== 'attachment') {
335      send($, { kind: 'row', uuid: stored.uuid ?? e.uuid, door: e.door })
336    }
337    return stored
338  })
339}
340
hooks/bridge.ts 108 lines
1// 엔진 이벤트를 kasaterm 에 보낼 모양으로 바꾸는 순수 셈. `$` 를 모르므로 시험이 그대로 부른다.
2// 계약은 레포의 docs/claude-mod-bridge.md.
3
4export type Task = { id: string; type: string; status: string; label: string }
5
6export type BridgeEvent = { kind: string; at: number; [field: string]: unknown }
7
8const PREVIEW_CAP = 400
9const LABEL_CAP = 160
10
11function one(text: string, cap: number): string {
12  const flat = text.replace(/\s+/g, ' ').trim()
13  return flat.length > cap ? `${flat.slice(0, cap - 1)}…` : flat
14}
15
16function field(input: unknown, key: string): string {
17  if (typeof input !== 'object' || input === null) return ''
18  const value = (input as Record<string, unknown>)[key]
19  return typeof value === 'string' ? value : ''
20}
21
22// 보드 활동에 싣는 글 — 줄바꿈은 살리고 길이만 줄인다.
23export function clip(text: string, cap: number): string {
24  return text.length > cap ? `${text.slice(0, cap - 1)}…` : text
25}
26
27// 승인 창이 무엇을 묻는지 한 줄로 — 원격 화면의 제목줄. 원문은 따로 실린다.
28export function permissionPreview(tool: string, input: unknown): string {
29  const pick = ['command', 'file_path', 'notebook_path', 'url', 'path', 'pattern', 'prompt', 'description']
30    .map(key => field(input, key))
31    .find(text => text !== '')
32  return one(pick ?? (input === undefined ? '' : JSON.stringify(input)) ?? '', PREVIEW_CAP) || tool
33}
34
35// 보드 활동 한 줄 — 무엇을 했나. 옛 기록 판독(`activity_from_tail`)이 쓰던 모양과 같은 정도의 짧은 말.
36export function activityLabel(tool: string, input: unknown): string {
37  const text = field(input, 'description') || permissionPreview(tool, input)
38  return one(text === tool ? '' : text, LABEL_CAP)
39}
40
41// tool.check 가 본 호출과 PermissionRequest 가 묻는 호출을 잇는 열쇠 — 후자는 tool_use_id 를 안 싣는다.
42export function askKey(tool: string, input: unknown): string {
43  return `${tool}\u0000${JSON.stringify(input ?? null)}`
44}
45
46// 백그라운드 알림 줄에서 끝난 작업의 id 와 상태를 읽는다(엔진이 쓰는 봉투 그대로).
47export function taskNotification(text: string): { id: string; status: string } | null {
48  const id = /<task-id>([^<]+)<\/task-id>/.exec(text)?.[1]?.trim()
49  if (!id) return null
50  const status = /<status>([^<]+)<\/status>/.exec(text)?.[1]?.trim() ?? 'completed'
51  return { id, status }
52}
53
54// 도구 결과가 백그라운드로 넘긴 작업. Bash 의 run_in_background·Ctrl+B 와 Monitor.
55export function launchedTask(tool: string, input: unknown, result: unknown): Task | null {
56  if (typeof result !== 'object' || result === null) return null
57  const r = result as Record<string, unknown>
58  const id = typeof r.backgroundTaskId === 'string' ? r.backgroundTaskId : typeof r.taskId === 'string' ? r.taskId : ''
59  if (!id) return null
60  const type = tool === 'Bash' ? 'shell' : tool === 'Monitor' ? 'monitor' : tool.toLowerCase()
61  return { id, type, status: 'running', label: activityLabel(tool, input) }
62}
63
64type StopTask = { id: string; type: string; status: string; description?: string; command?: string }
65
66// classic.Stop 이 주는 지금 도는 백그라운드 목록 — 그 순간의 정본.
67export function stopTasks(tasks: readonly StopTask[] | undefined): Task[] {
68  return (tasks ?? []).map(t => ({
69    id: t.id,
70    type: t.type,
71    status: t.status,
72    label: one(t.description || t.command || t.type, LABEL_CAP),
73  }))
74}
75
76type AgentRow = { id: string; description: string; type: string; status: string }
77
78// 서브에이전트(`$.agent.list()`)와 그 밖의 작업을 하나로 — 서브에이전트는 엔진 목록이 정본이다.
79export function mergeTasks(agents: readonly AgentRow[], others: readonly Task[]): Task[] {
80  const merged: Task[] = agents
81    .filter(a => a.type !== 'teammate')
82    .map(a => ({ id: a.id, type: 'subagent', status: a.status, label: one(a.description || a.type, LABEL_CAP) }))
83  for (const task of others) {
84    if (task.type !== 'subagent' && !merged.some(t => t.id === task.id)) merged.push(task)
85  }
86  return merged
87}
88
89export function running(tasks: readonly Task[]): Task[] {
90  return tasks.filter(t => t.status === 'running' || t.status === 'pending')
91}
92
93type Measure = {
94  context: { tokens?: number; window: number; percent?: number }
95  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
96  cost?: { usd: number }
97}
98
99export function usageEvent(m: Measure, at: number): BridgeEvent {
100  return {
101    kind: 'usage',
102    at,
103    context: { tokens: m.context.tokens ?? null, window: m.context.window, percent: m.context.percent ?? null },
104    limits: m.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resets_at: l.resetsAt ?? null })),
105    cost_usd: m.cost?.usd ?? null,
106  }
107}
108