## 2026-05-04 — 세션 시작 구간 키스트로크 가드 → 서버 type-ahead 버퍼 사가

### 배경

세션을 막 시작한 직후 사용자가 빠르게 타자를 치면 입력이 silently 사라지거나, 의도치 않게 엔터로 제출되거나, 보류 중인 초기 프롬프트가 통째로 날아가는 회귀가 여러 결로 겹쳐 있었다. PTY가 살아 있기까지의 구간 — (1) 폼 description이 서버 `pendingInitialPrompts` 맵에 stash 되어 Send/Skip 배너가 떠 있는 시점, (2) `POST /start` 응답 전 `process_pid=0` 구간, (3) `pendingFit`/`starting` phase 동안의 fit 대기 — 어느 한 곳에서든 키가 새거나 막혀 사용자 시점엔 "내가 타이핑한 채팅이 날아갔다"로 보이던 버그를 단계적으로 조여 들어가다, 결국 **클라에서 막는 대신 서버가 구간 동안 들어오는 입력을 큐에 모아두었다가 PTY가 살자마자 같은 순서로 replay** 하는 형태로 정착했다. 또 한 가지 별도 갈래로, 빈 description 세션에서 어댑터가 `'\n'`을 stdinPrompt로 합성해 ready 시점에 한 글자 엔터를 PTY로 흘리는 잠재 버그도 함께 고쳤다.

### 주요 변경

#### 1. Send/Skip 배너 동안 양쪽에서 키 차단 (`73072b2`)

배너는 absolute 레이어일 뿐이라 xterm 입력은 살아 있어, 사용자가 배너를 누르지 않은 채 타자를 치면 그 키만 PTY로 흘러가고 description은 영영 안 보내지던 버그를 자동 send/skip 같은 destructive 동작 대신 명시적 차단으로 해결.

- **`src/client/src/components/SessionTerminal.tsx`** — `inputBlocked` prop + `guardedSend` wrapper로 `session:terminal-input`만 swallow. `resize`/`subscribe`/`unsubscribe`는 통과. 차단 대상: `term.onData` 키스트로크, 브라우저 paste 이벤트, 이미지 paste, Ctrl/Cmd+V·X, Alt+V, 모바일 IME 컴포저, Hangul flush 등 모든 PTY 입력 경로.
- **`src/client/src/components/group/SessionPane.tsx`** — `pendingPromptLength!==null`일 때 `inputBlocked={true}` 전달. 배너에 warning 톤 안내 라인 + 좌측 3px warning accent border 추가.
- **`src/server/websocket/index.ts`** — 방어 in depth. `sessionManager.hasPendingPrompt()` 시 `session:terminal-input` drop. 클라이언트가 새도 서버에서 막힘.
- **i18n**: `session.initialPrompt.blocked` 한/영 키 추가.

#### 2. pendingFit/starting phase 키 차단 확장 (`e0ad308`)

직전 fix는 description이 채워진 세션에서만 동작. 빈 description이면 `pendingInitialPrompts` 맵이 비어 `inputBlocked` 조건이 false로 떨어지고, `POST /start` 응답이 돌아오기 전 친 키는 WS 핸들러의 status/process_pid 게이트에서 silently drop — 시각 피드백 없이 글자가 사라지던 후속 버그.

- **`SessionPane.tsx`** — 가드 트리거를 `phase === 'pendingFit' || phase === 'starting' || pendingPromptLength !== null`로 확장. 클라이언트에서 PTY 미준비 구간을 직접 가드.
- **`SessionPane.tsx`** — starting 오버레이에 warning 톤 안내 라인 추가, Send/Skip 배너 톤과 통일.
- **i18n**: `session.starting.keysIgnored` 한/영 키 추가.

#### 3. 서버 startup input 버퍼링으로 전면 교체 (`91fed94`)

직전 두 fix는 키를 *막아* 서버 dropping 게이트를 우회시키는 방식이었지만, 한글 IME 컴포지션 결과가 막힘 구간에 걸리면 일부 jamo가 통째로 유실되는 사이드 이펙트가 있었음. 키를 막는 대신 클라는 평소처럼 송신, 서버가 spawn 동안 들어오는 입력을 세션별 큐에 모아두었다가 PTY가 살자마자 replay 하도록 전환.

- **`src/server/services/session-manager.ts`** — `startupInputBuffer` Map 필드 추가. status='running' flip 직전으로 pending-prompt 결정을 끌어올려 gate 1이 spawn 윈도우 전체를 커버하게 하고, 직후 빈 큐 초기화. 워크트리/스폰 실패 경로와 stop/exit 핸들러에 큐 cleanup 추가. 마지막에 `updateSession({process_pid})` → 큐 get/delete → `writeStdinRaw` 루프를 단일 동기 블록에 묶어 race 없이 drain.
- **`session-manager.ts`** — `writeTerminalInput(sessionId, input)` 메서드 신설. 큐가 있으면 push, 없으면 PTY 직접 write.
- **`websocket/index.ts`** — `terminal-input` 핸들러를 `claudeManager.writeStdinRaw` 직접 호출에서 `sessionManager.writeTerminalInput` 라우팅으로 변경. gate 1 (`hasPendingPrompt`)은 그대로 유지.
- **`SessionPane.tsx`** — 직전 fix의 `inputBlocked` phase 확장을 원복(서버 버퍼링이 대체). starting/pendingFit/stopping 오버레이에 `pointerEvents:'none'` 추가해 클릭이 xterm으로 통과 → 사용자가 시작 단계에 터미널 포커스 가능. starting 안내 메시지를 "ignored" → "type-ahead" 톤으로 갱신.
- **`SessionTerminal.tsx`** — 마운트 시 helper textarea 자동 포커스 (`document.activeElement === body` 일 때만, 다른 input 빼앗지 않게). IME 컴포지션이 시작 직후 바로 터미널에서 시작되도록.
- **i18n**: `session.starting.keysIgnored` → `session.starting.typeAhead`로 키 이름과 값 (한·영) 모두 갱신.

#### 4. 빈 prompt에서 stray 엔터 방지 + 한글 IME helper 사전 위치 (`2695764`)

세션은 실제 prompt를 `pendingInitialPrompts` 맵에 stash 하고 Send 클릭 시 `writeToStdin`으로 따로 전달하므로 `startClaude`에는 빈 문자열이 들어옴. 어댑터의 `formatStdinPrompt('')`가 `'\n'`을 반환해 truthy `stdinPrompt`가 되고, `delayStdinUntilReady` 핸들러가 PTY ready 시점에 `'\n'` → submitSeq (`'\r'` / Gemini `'\r\n'`) 한 글자를 PTY로 써버려 사용자의 startup 큐 type-ahead가 엔터로 제출되던 잔여 회귀.

- **`src/server/services/claude-manager.ts`** — `needsStdin && prompt` 가드를 추가해 빈 prompt면 stdinPrompt 자체를 만들지 않도록 변경. orchestrator/discussion-orchestrator는 항상 실제 prompt를 넘기므로 영향 없음.
- **`SessionTerminal.tsx`** — `positionHelperAtCursor` 추가. xterm.js의 CompositionHelper가 `compositionupdate`에서만 textarea 위치를 갱신하고 `compositionstart` 시점엔 디폴트 `left:-9999em` 그대로 둬서, OS IME 후보창이 첫 자모를 그릴 때 viewport 우하단 같은 엉뚱한 위치에 뜨는 버그를 mount 직후 + compositionstart capture 페이즈에서 cell 픽셀 크기 측정 후 helper textarea를 `term.buffer.active.cursorX/Y`로 옮기는 방식으로 보정. private renderService 안 건드리고 공개 API + DOM 측정만 사용. 모바일은 자체 overlay 컴포저라 영향 없음.

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/server/services/session-manager.ts` | `startupInputBuffer` Map + `writeTerminalInput()` 메서드. spawn 구간 입력을 세션별 큐에 모아두었다가 PTY ready 시 동기 drain |
| `src/server/services/claude-manager.ts` | 빈 prompt면 stdinPrompt 합성 자체를 skip (`needsStdin && prompt` 가드) |
| `src/server/websocket/index.ts` | `terminal-input` 핸들러를 `sessionManager.writeTerminalInput` 라우팅으로 변경. `hasPendingPrompt` gate 유지 |
| `src/client/src/components/SessionTerminal.tsx` | `inputBlocked` prop + `guardedSend`. mount 시 helper textarea 포커스 + cursor cell로 위치 보정 |
| `src/client/src/components/group/SessionPane.tsx` | Send/Skip 배너 동안 inputBlocked. starting/pendingFit 오버레이에 `pointerEvents:'none'` |
| `src/client/src/i18n.tsx` | `session.initialPrompt.blocked`, `session.starting.typeAhead` 한/영 키 |

### 아키텍처 결정

1. **클라 차단 vs 서버 버퍼링**: 세션 시작 후 PTY 살아나기 전 구간에 들어오는 키를 막는 것 (1, 2번)에서 모아두는 것 (3번)으로 두 차례 갈아탄 사가. 막기는 구현 단순하지만 한글 IME 컴포지션 결과가 차단 구간에 걸리면 jamo가 통째로 유실되는 사이드 이펙트가 있어, 결국 사용자 멘탈 모델 그대로 ("type-ahead가 그대로 명령줄에 들어감") 동작하도록 서버 사이드 큐로 정착. 큐는 세션별 `Map<string, Buffer[]>` 한 개, drain은 process_pid 갱신과 같은 동기 블록 안에서 처리해 race 없음.
2. **빈 prompt에서 stdinPrompt 자체를 만들지 않기**: `formatStdinPrompt('')`가 어댑터마다 `'\n'`을 반환하는 동작 자체는 유지(orchestrator 흐름과의 호환). 호출 측에서 `prompt`가 비었을 때 stdinPrompt 합성을 skip 하는 게 가장 작은 변경. 어댑터 시그니처/로직은 무조건 보존하는 방향.
3. **IME helper 위치는 공개 API + DOM 측정만**: `_core._renderService.dimensions.css.cell.{width,height}` 같은 private API는 미가용 시 fallback 만들고 메인 경로는 `.xterm-screen` BCR + cols/rows로 cell 픽셀 추정. 첫 자모 시점엔 cursor 위치에 helper textarea가 미리 와 있어야 OS IME 후보창이 정상 위치에 뜸 — xterm.js의 자체 CompositionHelper는 `compositionupdate`에야 위치를 갱신하므로 더 일찍 한 번 끼워 넣음.

---
