# AgentDeck Development Log — 2026-04

> Archived monthly slice. Back to the active log: [DEVELOPMENT_LOG.md](../../DEVELOPMENT_LOG.md). Index of all archives: [docs/devlog/README.md](README.md).

---

## 2026-04-30 — Codex 숫자 session_id ghost creature 차단

### 문제

macOS daemon 의 `/status.sessions` 에 실제 사용자-facing Codex thread 와 별개로 `codex:8`, `codex:5`, `codex:2` 같은 짧은 숫자형 Codex 세션이 합성됐다. 이 항목들은 `projectName` 이 비어 있어 Dashboard / D200H 에 이름 없는 Codex 크리처처럼 보였고, 실제 활성 Codex 세션 수와 화면에 노출되는 크리처 수가 맞지 않았다.

### 해결

- Codex hook identity 해석을 `CodexHookIdentity` 로 분리했다.
- `thread-id` / `thread_id` / `threadId` / `codex.thread_id` / `thread.id` 를 최우선으로 사용한다.
- `session_id` fallback 은 12자 미만 또는 숫자-only 값이면 durable thread id 로 보지 않고 무시한다. 따라서 turn/tool/companion 단위로 보이는 `session_id: "8"` 같은 payload 는 `codex:8` 세션으로 승격되지 않는다.
- 회귀 방지 테스트를 추가해 thread id 우선순위, 숫자형 fallback 거부, UUID형 fallback 허용을 고정했다.

### 검증

- `git diff --check` 성공
- `xcodebuild test -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS,arch=arm64' -only-testing:AgentDeckTests_macOS/CodexOtelParserTests -derivedDataPath /tmp/AgentDeckDerivedDataCodexIdentity CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-29 — Codex observation startup port-read 제거

### 문제

로컬 배포 검증 중 macOS 앱이 `DaemonServer.startServices()` 의 Codex observation 설치 단계에서 main actor 를 오래 점유했다. 샘플링 결과 `CodexConfigInstaller.installIfNeeded()` 가 OTel endpoint 를 만들기 위해 방금 시작 중인 daemon 의 `daemon.json` 을 동기 읽는 경로에 머물렀고, 그 사이 HTTP/WS/D200H refresh 요청이 포트 연결 후 응답을 받지 못했다.

### 해결

- `CodexConfigInstaller.installIfNeeded(daemonHttpPort:)` 를 추가해 `DaemonServer` 가 이미 알고 있는 bound port 를 직접 전달한다.
- startup 경로에서는 `daemon.json` 재읽기를 피하고, Settings 등 수동 재설치 경로만 기존 fallback read 를 유지한다.

### 검증

- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-29 — macOS WebSocket upgrade deadlock 수정

### 문제

macOS in-process daemon 이 `9120` 에서 정상 기동한 뒤에도 Dashboard `BridgeConnection` 이 `ws://127.0.0.1:9120` 로 연결되지 않고 약 60초 뒤 `Ping failed` / `Receive error: The request timed out` 를 반복했다. 서버 로그는 클라이언트 타임아웃 직후에야 `WS: Client connected` 를 찍고 곧바로 `Connection reset by peer` 로 닫혔다.

원인은 `WebSocketServer.handleNewConnection` 이 첫 TCP 청크를 읽은 뒤 `HTTPServer.receiveFullRequest(accumulated:)` 에 넘겼지만, `receiveFullRequest` 가 이미 누적된 완전한 WebSocket upgrade 헤더를 먼저 판정하지 않고 추가 `receive` 를 먼저 기다린 점이었다. WebSocket 클라이언트는 `101 Switching Protocols` 응답 전에는 추가 바이트를 보내지 않으므로 서버/클라이언트가 서로 기다리는 deadlock 이 발생했다.

### 해결

- `HTTPServer.receiveFullRequest` 가 누적 버퍼를 먼저 검사하고, headers/body 가 이미 완전하면 즉시 completion 을 호출하도록 변경했다.
- 누적 버퍼가 불완전할 때만 추가 `NWConnection.receive` 를 걸도록 request completion 판정을 helper 로 분리했다.
- Codex OTel 대용량 POST 를 위해 추가했던 full-body read 경로는 유지하면서, body 없는 WebSocket upgrade 요청은 즉시 handshake 로 넘어가게 했다.

### 검증

- `git diff --check` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataWsHandshake CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-29 — Stream Deck/D200H icon-rich no-session + detail redesign

### 문제

Stream Deck / Stream Deck+ / D200H 의 no-session 및 session detail 화면에 텍스트만 있는 버튼이 남아 있었다. 특히 daemon 은 연결됐지만 세션이 없는 상태가 `Empty` 나 단순 라벨로 읽히면 recovery 상황과 idle 상황이 구분되지 않고, D200H 는 native label 숨김 설정에서 일부 상태 텍스트가 사라질 수 있었다.

### 해결

- shared SVG renderer 를 icon-rich card 체계로 재작성했다. `OPEN APP`, `RETRY`, `HUB OFF`, `HUB READY`, `NO SESSION`, `AgentDeck`, `BACK`, `MORE`, `ESC`, `STOP`, option/status/info 카드가 모두 자체 아이콘을 그린다.
- Stream Deck list no-session 은 `HUB READY / CONNECTED`, `NO SESSION / WAITING`, `AgentDeck / IDLE` 3장 카드로 정의하고, 빈 칸은 텍스트 없는 quiet tile 로 유지한다.
- Session detail 은 idle quick actions 뒤에 MODEL/MODE/READY 카드를 채우고, awaiting option 은 allow/deny/diff/option 아이콘 카드로 렌더한다. processing 은 tool/status 카드를 항상 첫 content slot 에 둔다.
- D200H optionSelect/no-session 도 같은 의미 체계를 따르도록 `infoTile` baked overlay 와 hub/noSession/agentDeck/model/ready/option/esc glyph 를 추가했다. D200H renderer revision 은 `creature-session-icons-v27` 로 올렸다.

### 검증

- `pnpm --filter @agentdeck/shared build` 성공
- `pnpm vitest run plugin/src/__tests__/renderer-snapshots.test.ts plugin/src/__tests__/connection-manager.test.ts plugin/src/__tests__/session-slot-manager.test.ts` 성공
- `pnpm --filter @agentdeck/shared typecheck`, `pnpm --filter @agentdeck/plugin typecheck`, `pnpm --filter @agentdeck/bridge typecheck` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataIconButtonRedesign CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-29 — Daemon 로그 기반 안정화 일괄 수정

### 문제

실행 로그에서 daemon 자체는 9120 충돌 후 9121 로 정상 기동했지만, 몇 가지 실제 결함이 함께 보였다. `CodexOTel` POST 는 `Content-Length` 가 수십만~백만 바이트인데 `bodyLen` 이 65 KB 근처로 잘려 JSON 파싱이 반복 실패했다. `focus_session` 은 hook/OTel 로 관찰된 로컬 Codex 세션을 실제 sibling daemon 세션처럼 `SessionRegistry` 에서 찾다가 "Session ... not found" 를 찍었고, D200H/대시보드에서 선택한 세션 명령이 잘못된 fallback 경로로 흐를 수 있었다. 진단 payload 생성은 main actor 에서 `DaemonLogger.recentLines()` 의 semaphore wait 를 호출해 Thread Performance Checker priority inversion 을 만들었다. Pixoo `192.168.68.110` 은 offline 인데 frame push 가 probe 주기마다 다시 시도되어 CFNetwork 실패 로그가 계속 누적됐다. 별도로 작업 트리에 있던 D200H detail-tile 변경은 `infoTile`/아이콘 enum case 가 없어 macOS 빌드를 막고 있었다.

### 해결

- `WebSocketServer` 의 첫 read 경로도 `HTTPServer.receiveFullRequest` 를 재사용하게 바꿨다. WebSocket upgrade 판별 전에 HTTP header/body framing 을 끝내므로 Codex OTel 대용량 JSON POST 가 65 KB 에서 잘리지 않는다.
- `SessionFocusRelay` 에 daemon-local observed session focus 모드를 추가했다. 로컬 hook/OTel 세션은 WebSocket relay 대상이 아니라 UI 선택 상태로만 유지하고, 명령 라우팅은 처리됨으로 간주해 다른 Claude/Codex 세션으로 fallback 되지 않게 했다.
- `DaemonLogger.recentLines` 를 async continuation 기반으로 바꿔 main actor 가 낮은 QoS file-read queue 를 semaphore 로 기다리지 않게 했다.
- Pixoo backoff 를 "일시 정지 + due probe" 모델로 바꿨다. 실패 임계치 이후 frame push 는 멈추고, exponential backoff 만료 시 조용한 probe 로 복구 여부를 확인한다.
- D200H detail-tile 렌더링에서 이미 참조하던 `infoTile` overlay 와 model/ready/option/esc 등 아이콘 glyph 를 추가해 기존 UI 변경이 컴파일되도록 맞췄다.

### 검증

- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataLogHardening CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild test -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataLogHardening CODE_SIGNING_ALLOWED=NO` 는 `AgentDeck.app` 을 띄운 뒤 5분 이상 종료되지 않아 중단했다. 빌드 검증은 통과했지만 이 테스트 scheme 은 별도 timeout/host-app 설정 점검이 필요하다.

---

## 2026-04-28 — Daemon bounded-read queue QoS + OpenClaw RPC 로깅

### 문제

macOS daemon 로그가 매 tick 마다 Thread Performance Checker priority-inversion 백트레이스로 도배됐다. `SessionRegistry / PixooModule / ApmeSettings / WifiConfig / UsageAPIClient / DaemonTimelineStore` 6 개 모듈이 모두 `.utility` QoS 의 dispatch queue 에서 `DispatchSemaphore.wait` 로 700 ms bounded read 를 흉내 냈는데, 호출자가 main actor (User-interactive) 나 `.userInitiated` Task 라 매번 "higher-QoS thread waiting on lower-QoS" 경고가 발생했다. 별도로 OpenClaw Gateway adapter 의 RPC 실패 로그가 `code ?? message ?? "unknown"` 의 `??` 체인을 써 `INVALID_REQUEST` 같은 코드가 있을 때 `message` 가 항상 묻혔다. 그 결과 `models.list` (별도 경로) 만 "missing scope: operator.read" 가 보이고, `sessions.subscribe / system-presence / sessions.list / logs.tail` 는 코드만 찍혀 진짜 원인이 안 드러났다.

### 해결

- 6 개 큐 QoS 를 `.userInteractive` 로 올렸다. 처음에는 `.userInitiated` 로 1차 수정했는데 (commit `3cf32895`) 그건 `.userInitiated` Task 호출자만 해결하고 main actor 호출자에게는 여전히 한 단계 inversion 이 남았다 (User-interactive → User-initiated). DaemonServer.startServices / ApmeRunner.init / probeMLX / startDeviceModules / TimelineStore.start / 초기 usage task / query_usage 핸들러가 모두 main actor 에서 sync wait 한다. 700 ms cap + 단일 `Data(contentsOf:)` 라 워커가 main actor critical path 위에 있는 짧은 구간만 .userInteractive 으로 도는 셈이라 정당화됨.
- `OpenClawAdapter` 의 RPC 실패 로그를 `code: message` 둘 다 찍게 바꿨다. `code` 가 있으면 `message` 도 같이 출력 → 다음 페어링 사이클부터 scope 미스매치가 즉시 보인다. 실제 scope grant 자체는 Gateway 서버 + 페어링 플로우 문제라 클라이언트로는 못 고친다.

### 핵심 설계 결정

- **Sync semaphore wait 패턴은 가장 높은 호출자 QoS 와 매칭해야 한다.** `.utility` 는 TPC 경고 보장. `.userInitiated` 는 main actor 가 호출하면 한 단계 inversion 이 남는다. main actor 호출자가 있는 큐는 `.userInteractive` 가 정답 (단, work 이 짧고 cap 있을 때만). 새 모듈은 호출 chain 부터 보고 결정해라.
- **Optional chain 으로 에러 컨텍스트를 합치지 마라.** `errorInfo?["code"] ?? errorInfo?["message"]` 같은 패턴은 한쪽만 흘려보내고 나머지를 영원히 가린다. 둘 다 보고 싶으면 `switch (code, message)` 로 분기.

---

## 2026-04-28 — D200H OpenClaw / macOS Codex 세션 아이콘 보정

### 문제

D200H 세션 타일의 OpenClaw 캐릭터가 공식 crayfish SVG 를 CGPath 로 옮긴 뒤 여러 path 를 하나의 even-odd clip 으로 합쳐 gradient fill 했다. OpenClaw 의 집게와 몸통 path 는 서로 겹치기 때문에, 전체 path union 에 even-odd 를 적용하면 겹친 부분이 구멍처럼 상쇄되어 작은 D200H 버튼에서 찌그러진 형상으로 보일 수 있었다. macOS Dashboard 왼쪽 SessionListPanel 의 Codex 아이콘은 13pt Image frame 에 공식 SVG 를 꽉 맞춘 뒤 바깥 padding 만 줘, viewBox 가장자리까지 닿는 Codex path 의 anti-aliasing 픽셀이 좌상단에서 잘려 보일 수 있었다.

### 해결

- D200H `fillSvgPathsGradient` 가 even-odd fill 을 path별로 적용하도록 바꿔 OpenClaw shell/claw overlap 이 상쇄되지 않게 했다.
- D200H renderer revision 을 `creature-session-icons-v24` 로 올려 cached PNG 를 무효화했다.
- `SessionCreatureIcon` 에 내부 inset 옵션을 추가하고, macOS SessionListPanel 은 16pt slot 안에서 Codex 2pt / OpenClaw 1.8pt / 기타 1.5pt 내부 inset 으로 렌더한다. 바깥 padding 대신 SVG 자체를 slot 내부로 줄여 edge clipping 을 방지한다.

### 검증

- `git diff --check` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataD200hOpenClawIcon CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_iOS -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200hOpenClawIconIOS CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-28 — Pixoo hot-reload / offline 상태 오표시 수정

### 문제

Pixoo64 설정에는 `192.168.68.110` 이 저장되어 있는데 실행 중인 macOS daemon 의 `/health` 와 `/devices` 는 Pixoo `configuredDeviceCount: 0`, `/pixoo/frame` 은 204 로 응답했다. Swift `PixooModule.start()` 가 시작 시점에 설정된 Pixoo 가 없으면 render/probe loop 를 만들지 않고 즉시 return 해, 이후 Settings UI 에서 장치를 추가해도 데몬 재시작 전까지 Pixoo 가 없는 장치처럼 보였다. Device summary 도 프레임 생성 전/일시 push 실패 상태를 idle/offline 처럼 읽히게 했다.

### 해결

- `PixooModule` 이 시작 시 Pixoo 가 없어도 계속 살아 있도록 변경하고, 5초마다 `settings.json` 의 `pixooDevices` 를 hot-reload 한다.
- 설정 변경 시 새 장치만 준비하고, 제거된 IP 의 PicID/log state 를 정리하며, 장치가 모두 없어지면 `/pixoo/frame` shadow 를 비운다.
- wake recovery 때도 설정을 다시 읽고 PicID cache 를 재동기화한다.
- Pixoo Settings 문구에서 daemon restart 요구를 제거했다.
- macOS Dashboard / menubar device summary 에서 Pixoo 의 `warming up`, `retrying`, `retry paused` 상태를 분리해 단순 워밍업을 offline 으로 오해하지 않게 했다.

### 검증

- `git diff --check` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataPixooHotReload CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_iOS -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath /tmp/AgentDeckDerivedDataPixooHotReloadIOS CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-28 — macOS session icon clipping / Pixoo Codex / Device Preview 현실감 개선

### 문제

macOS Dashboard 왼쪽 SessionListPanel 은 Codex/OpenClaw 아이콘을 공식 asset renderer 가 아니라 13×13 Canvas 에 SVG path 를 꽉 채워 직접 렌더했다. 두 path 모두 viewBox 경계까지 닿아 있어 macOS 안티앨리어싱에서 왼쪽 픽셀이 살짝 잘려 보일 수 있었다. Pixoo64 Codex cloud 는 10×8 sprite 라 Claude octopus 대비 납작하고 작게 읽혔고, `>_` 마킹도 1~2px 수준이라 실제 LED matrix 에서 티가 약했다. Device Preview 의 일부 non-key device mock 은 실제 화면 구조보다 큰 단일 creature 중심으로 보여 device별 HUD/세션/타임라인 밀도를 판단하기 어려웠다.

### 해결

- macOS `SessionListPanel` agent icon 을 공식 `SessionCreatureIcon` asset renderer 로 통일하고 16×16 slot 안에 13pt glyph + inset 을 둬 clipping 여유를 확보했다.
- Pixoo64 Codex sprite 를 13×11 cloud + 9×7 LOD 로 키우고, `>_` 마킹을 near-white 5px 패턴으로 재작성했다. Processing pulse 가 글씨 대비를 씻어내지 않도록 body pulse mix 도 낮췄다. Swift `PixooRenderer` port 와 Node `bridge/src/pixoo/pixoo-sprites.ts` 를 같은 grid 로 맞췄다.
- Device Preview 공용 building block (`PreviewCreatureGlyph`, mini session list, mini topology, aquarium scene, timeline strip) 을 추가했다.
- iPad / Android tablet preview 는 좌측 session list + 중앙 aquarium + 우측 topology rail 구조로 바꾸고, Android tablet 은 최근 실제 tablet HUD 조밀도에 맞춘 작은 sidebar/rail 비율을 반영했다.
- E-ink mono/color preview 는 header, session column, creature status, usage gauge, TIMELINE 영역을 가진 실제 e-book 화면식 레이아웃으로 교체했다.
- ESP32 / D200H key preview 도 badge형 거대 creature 대신 bare glyph 를 써서 실제 device HUD 안의 creature 크기에 가깝게 조정했다.

### 검증

- `pnpm --filter @agentdeck/bridge typecheck` 성공
- `pnpm vitest run bridge/src/__tests__/pixoo-sprites.test.ts` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCreaturePreview CODE_SIGNING_ALLOWED=NO` 성공
- `git diff --check` 성공

---

## 2026-04-28 — Android tablet HUD 밀도 / e-book timeline 정리

### 문제

Android tablet HUD 가 macOS/iPad HUD 대비 과하게 커졌다. 좌측 SessionListPanel 은 340dp cap + 14dp padding + 14/12sp typography 로 넓고 성긴 카드처럼 보였고, 우측 TopologyRail 의 AgentDeck hub 는 macOS 의 inline spine row 가 아니라 별도 boxed card 로 렌더되어 시각 언어가 어긋났다. e-book 화면은 Codex cloud 식별성은 개선됐지만 timeline 영역에 제목/구획 신호가 없어 빈 흰 영역 아래 이벤트가 갑자기 노출되는 형태였다.

### 해결

- Android tablet `MonitorLayoutScale` 을 macOS HUD 비율로 되돌림: 좌측 max 220dp, 우측 max 300dp, edge 12dp, panel padding 8dp, body/sub/header 12/10/11sp.
- `MonitorHUD` tablet width ratio 를 SwiftUI 와 동일하게 좌측 `min(width * 0.22, 220)`, 우측 `min(width * 0.32, 300)` 으로 맞췄다.
- Android `TopologyRail` 의 boxed AgentDeck hub 를 제거하고 macOS 처럼 vertical spine + inline hub row (`AgentDeck :port` + hairline) 로 교체했다.
- Android downstream 설명 문구를 제거하고 `This tablet · dashboard client` 한 줄만 남겨 operational density 를 높였다.
- e-book `EinkEventLog` 상단에 `TIMELINE` 헤더를 추가해 구획을 명확히 했다.

### 검증

- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`
- Lenovo tablet, Pantone6, CremaS 에 APK 재설치 및 `adb reverse tcp:9120 tcp:9120` 적용
- 실기기 screencap 확인: `/tmp/agentdeck-lenovo-final.png`, `/tmp/agentdeck-pantone6-final.png`, `/tmp/agentdeck-cremas-final.png`
- `git diff --check` 성공

---

## 2026-04-28 — OpenCode adapter silent-failure 가시화 (`7bd6afec`)

### 문제

사용자 보고: macOS Apple 대시보드에서 OpenCode 크리처가 보이지 않음. SessionListPanel + `agentdeck status` **양쪽 다** 비어 있음 → Terrarium 매핑 단계가 아니라 upstream 등록 단계 누락. 정적 분석으로 확인된 OpenCode 의 시스템 진입 경로는 단 한 가닥 (`agentdeck opencode` → DaemonWsClient → `session_push_register`) 뿐 — Claude Code 처럼 hook 자동발견 없음.

### 해결

두 단계 하드닝 — 사용자가 어디서 끊겼는지 stderr 로 즉시 보이게:

1. **`bridge/src/check-deps.ts:25-30`** — `AGENT_DEPS` 에 `opencode` 항목 추가. PATH 에 `opencode` 바이너리가 없으면 `[agentdeck] ERROR: opencode not found. Install: brew install sst/tap/opencode (or: npm i -g opencode-ai)` + `exit 1`. claude-code / codex-cli 와 parity.

2. **`bridge/src/adapters/opencode-adapter.ts`** — `connectToEmbeddedServer` 의 4 개 silent `return` 분기 (15s 폴링 timeout, `/global/health` 실패, session resolve 실패, SSE subscribe 실패) 를 `debug` 로깅에서 `stderrLog` 로 격상. PTY 절반은 살아있다는 사실을 메시지에 명시 ("TUI still works, state events missing") 해서 사용자가 재시작 vs 부분 모드 수용을 판단 가능.

### 핵심 설계 결정

- **App Store 모드는 OpenCode 미지원이 의도된 분리** — `docs/appstore-feature-matrix.md:47` 에 명시. OpenCode embedded server 가 random-port + no lock-file 이라 sandbox 에서 passive discovery 불가. App Store 단독 모드에서 OpenCode 크리처 부재 = bug 아님. 외부 Node CLI (`agentdeck daemon` + `agentdeck opencode`) 동시 운영해 `DaemonService.isUsingExternalDaemon=true` 일 때만 sibling 으로 흘러들어감. Apple 측 `OpenCodeCreature.swift` / `syncOpenCode` 등은 CLI-coexists 시나리오 전용 — dead code 아님.

- **Outer `.catch(stderrLog)` 신뢰 금지 패턴** — Codex stop-gate review 가 1차 fix (outer wrapper 만 격상) 가 무용함을 잡아냄. 이유: `connectToEmbeddedServer` 가 실패 시 throw 가 아니라 silent `return` → outer catch 미발화. 같은 패턴 다른 adapter 에도 있을 가능성 (codex-cli, openclaw) — 차후 audit 시 inner 분기까지 봐야.

- **세션이 안 보이면 Terrarium 부터 의심하지 말 것** — sessions_list 가 비어있으면 100% upstream (registration / discovery) 문제. SessionListPanel 와 `agentdeck status` 양쪽이 동시에 비어있다 = Node bridge 가 sessions.json 에 못 들어왔다 = bridge process 자체가 없거나 crash.

### 검증

- `pnpm vitest run bridge` 785/785 통과 (회귀 없음)
- `tsc --noEmit` 통과
- 사용자 진단 절차: `which opencode` → `agentdeck opencode --debug` → 별도 터미널에서 `agentdeck status`. 어느 줄에서 끊기는지가 root cause

---

## 2026-04-28 — Apple dashboard timeline 중복 행 + Codex stop-gate 회귀 체인

### 문제

사용자가 macOS Dashboard timeline 에 같은 응답이 두 줄씩 표시된다고 보고. 초기 진단은 Node bridge `wireClaudeCodeTimeline` 의 Stop hook + PTY fallback dual-emit race (Stop 이 fallback 보다 1.5s+ 늦으면 같은 turn 이 두 번 emit) 로 잡았으나, 실제 사용자 환경은 `agentdeck claude` 를 안 돌리고 Apple in-process Swift daemon 이 직접 hook 을 받는 구조였다. 같은 증상의 다른 root cause: `appendClaudeCodeChatEnd` 가 `chat_response` 와 `chat_end` 를 emit 하는데 둘 다 응답 텍스트 prefix 를 `raw` 로 사용 → UI 의 chat_end 행이 `isChatEnd` opacity 0.4–0.6 으로 dim 처리되어 같은 내용이 한 번은 밝게, 한 번은 회색으로 표시되어 시각적 중복이었다.

### 해결

Stop-gate 자동 리뷰가 fix 의 회귀를 단계별로 잡아내 총 8 차 반복:

1. **Node bridge 측 race fix** — `wireClaudeCodeTimeline` Stop 핸들러에 `wasPending` 가드 추가 (fallback 이 이미 emit 했으면 skip), `chat_response` 를 `isRepetitiveEntry` 화이트리스트에 추가, exact dedup 윈도우 5→8s 확대 (`shared/src/timeline.ts`, Apple `DaemonTimelineStore.swift` 양쪽). 회귀 방지 vitest 2 케이스.
2. **Apple chat_end 메타데이터화** — `claudeChatStartTsBySession` in-memory map 추가, `appendClaudeCodeChatStart` 끝에 ts 기록, `appendClaudeCodeChatEnd` 의 `endRaw` 를 응답 prefix 가 아닌 `Completed · ${duration}s` 로.
3. **Codex 1차 stop-gate**: `Completed · Ns` 만 쓰면 같은 round duration 두 빠른 turn 이 8s 윈도우에서 collapse → topic hint 라벨 추가. Swift 포트 `extractTopicHint(from:)` (`shared/src/timeline-summarizer.ts:18` 동등). 응답 → prompt → "Completed" fallback.
4. **Codex 2차 stop-gate**: full prompt 보관 + cleanup 누락 → bounded topic prefix (≤80 char) 만 저장 (`claudeLastPromptTopicBySession`), chat_end / `case "session_end"` / `evictStaleHookSessions` 3 군데 cleanup 추가.
5. **Codex 3차 stop-gate**: 비정상 종료 (Stop hook 미발사) + 새 turn 의 unparseable prompt 일 때 `appendClaudeCodeChatStart` 의 `guard !prompt.isEmpty` early-return 이 cleanup 을 우회 → 함수 시작부에 unconditional invalidate. 4-site cleanup 패턴 완성.
6. **시각 중복 잔존**: chat_end raw 가 topic hint (응답 첫 줄 truncate) 라 chat_response 의 첫 줄과 첫 80 chars 가 동일 → 시각적 중복 그대로. `Completed · ${duration}s · ${topic}` prefix 로 시각 분리.
7. **UX 최종 정리**: 사용자가 "한 줄 더 나오는 현상" 으로 felt → `TimelineStripView.grouped` 에서 claude-code chat_end 를 filter out. daemon 은 그대로 emit (Pixoo / D200H / plugin / APME 가 turn 종료 marker 로 사용) 하지만 dashboard timeline panel 에는 한 turn = 한 row.
8. **빈 timeline title 정렬**: `HStack(alignment: .top, spacing: 0)` 으로 변경 — empty timeline 컬럼이 detail pane 의 vertical center 로 끌려가서 "TIMELINE" 타이틀이 가운데 떠 보이던 현상 해소.

### 핵심 설계 결정

- **이중 root cause**: 같은 사용자 증상이지만 Node bridge 와 Apple daemon 모두 dual-emit 구조를 갖고 있어 양쪽 다 수정 필요했다. Node 는 race fix, Apple 은 chat_end 라벨 형식 + UI hide.
- **bounded value + 4-site cleanup 패턴**: per-session in-memory cache 도입 시 (1) bounded value (≤80 char topic 만), (2) 새 turn 진입 시점 upfront invalidate, (3) turn 정상 종료 cleanup, (4) session_end cleanup, (5) TTL eviction cleanup — 5 군데 모두 챙겨야 stale leak 없음.
- **chat_end UI hide vs daemon emit 분리**: chat_end 는 turn 종료의 canonical signal 이지만 Apple dashboard timeline panel 에서는 chat_response 가 이미 결론을 표시하므로 redundant. UI 단 filter 만 적용하고 daemon emit 은 유지 (다른 surface 가 사용).

### 검증

- vitest 1015/1015 통과 (timeline-integration 24/24, 새 race 회귀 2 건 포함)
- macOS scheme `xcodebuild build` 성공 (각 fix 단계마다 재빌드)
- daemon 재시작 후 `~/.agentdeck/timeline.json` 직접 검증 — chat_end raw 가 `Completed · Ns · topic` 형식, chat_response 와 다른 raw, dashboard 에선 hidden
- Codex stop-gate 통과 (3 단계 회귀 각각 별도 review 로 catch)

---

## 2026-04-28 — macOS Dashboard session list / terrarium sync 보강

### 문제

macOS Dashboard 에서 focus relay 가 특정 세션의 `state_update` 를 primary state 로 승격하는 순간, 왼쪽 SessionListPanel 은 같은 `agentType` 이 이미 `sessions_list` 에 있으면 primary row 를 숨겼다. 반면 테라리움은 focused `sessionId` 를 primary creature 로 렌더해, 특히 Codex/Claude 세션이 여러 개일 때 목록과 크리처가 순간적으로 어긋날 수 있었다. 또한 Codex row 의 OpenAI knot mark 는 SVG 원본의 `evenodd` fill rule 을 적용하지 않아 내부 홀이 살짝 깨져 보였다.

### 해결

- MonitorScreen 의 테라리움 파생 상태 갱신 키에 focused `sessionId`, `agentType`, project/model, sibling metadata 를 포함해 상태 문자열이나 세션 개수 변화가 없어도 크리처 모델이 즉시 갱신되게 했다.
- SessionListPanel 의 primary row 중복 제거 기준을 `agentType` 에서 `sessionId` 로 좁혀, focus-relayed primary session 과 `sessions_list` 의 동일 row 만 병합한다.
- Codex row OpenAI logo path 렌더링에 `eoFill` 을 적용해 asset catalog 의 `fill-rule="evenodd"` 와 맞췄다.

### 검증

- `git diff --check` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataDashboardSessionSync CODE_SIGNING_ALLOWED=NO` 성공 (기존 generated `GatewayFrame.JSONNull.hashValue` deprecation warning 은 남음)

---

## 2026-04-28 — Agent Session 캐릭터 스케일 / ESP32-eink Codex 정합성

### 문제

직전 Agent Session 캐릭터 교체 이후 Codex cloud 는 방향성은 좋았지만 D200H/Stream Deck 축소 렌더에서 Claude/OpenClaw 보다 시각적으로 커 보이고 일부 컨테이너에서 살짝 잘려 보였다. OpenClaw 는 공식 실루엣을 단색으로만 축소해 눈과 얼굴이 읽히지 않아 붉은 덩어리처럼 보였다. ESP32 LVGL Codex 는 주석과 달리 6-lobe cloud 가 아니라 12×10 pill glyph 로 렌더되어 e-book/Android/D200H 와 캐릭터 언어가 어긋났다.

### 해결

- shared SVG session renderer 의 Codex cloud 유효 body scale 을 0.78 로 줄여 lobe 외곽이 48px icon box 안에 들어오게 했다.
- D200H CoreGraphics renderer 도 동일한 Codex body scale 을 적용하고 renderer revision 을 `creature-session-icons-v23` 으로 올려 캐시를 무효화했다.
- OpenClaw session icon 은 공식 24×24 실루엣의 body/claw path 를 유지하되, eye path 를 별도 dark eye + cyan highlight 로 렌더해 작은 버튼에서도 얼굴이 읽히게 했다.
- Android e-ink Codex lobe 값과 주석을 6-lobe cloud 기준으로 정리했다.
- ESP32 LVGL `Cloud::render` 를 pill glyph 에서 6-lobe cloud + `>_` prompt 로 교체했다.
- ESP32 flash helper 의 `auto` 경로가 다중 보드 응답 시 빈 env 로 진행하려는 문제를 수정하고, macOS `cu.wchusbserial*` 포트도 `device_info_request` 안전 식별 대상에 포함했다.
- Ulanzi TC001(CH340)은 460800 baud 업로드 중 응답이 끊겨 `upload_speed=115200` 을 env 기본값으로 고정했다.

### 검증

- `pnpm --filter @agentdeck/shared typecheck` 성공
- `pnpm --filter @agentdeck/plugin typecheck` 성공
- `pnpm vitest run plugin/src/__tests__/renderer-snapshots.test.ts -u` 성공 (1 file / 55 tests, snapshots 2 updated)
- `pnpm build` 성공
- `pio run -e box_86` 성공
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCharacterUX CODE_SIGNING_ALLOWED=NO` 성공 (기존 generated `GatewayFrame.JSONNull.hashValue` warning 은 남음)
- `streamdeck validate plugin/bound.serendipity.agentdeck.sdPlugin` 성공
- `pnpm package` 성공 → `dist/bound.serendipity.agentdeck.streamDeckPlugin`
- Stream Deck plugin link/restart 완료, macOS Debug app 재시작 완료, D200H status `rendererRev=creature-session-icons-v23` 확인
- 최신 D200H `set_buttons` dump 를 `pnpm d200h:preview` 로 변환해 contact sheet 확인
- Android/e-book 연결 기기 3대(Pantone6, CremaS, Lenovo tablet)에 `dist/agentdeck-v0.4.1.apk` 설치 및 `MainActivity` 기동 완료
- ESP32 4대(`ips_35`, `round_amoled`, `box_86`, `ulanzi_tc001`) 모두 명시 env+port 로 플래시 완료. TC001은 첫 PIO 업로드가 66%에서 끊겨 115200 baud direct esptool 로 재시도, hash verified.
- `bash -n esp32/scripts/flash.sh`, `pio run -e ulanzi_tc001`, `git diff --check` 성공
- macOS Debug app 재기동 후 daemon status: D200H connected, Stream Deck plugin process running, ESP32 serial `connectionCount=4`

---

## 2026-04-28 — Agent Session 공식 아이콘 + 세션 상세 UX 정리

### 문제

Stream Deck/Stream Deck+ preview/D200H 의 Agent Session 타일 일부가 `assets/logos` 및 Apple `Creature*` asset catalog 의 공식 캐릭터가 아니라 자체 변형된 마크를 렌더했다. 또한 세션 타일을 눌러 상세 화면에 진입한 직후 이전 focus 의 상태/options 가 잠깐 보이거나, 빠르게 STOP/option 을 누르면 daemon focus relay 지연 때문에 의도한 세션이 아닌 현재 focus 로 명령이 갈 수 있었다.

### 해결

- shared SVG renderer 의 Claude/Codex/OpenClaw session icon 을 공식 `assets/logos/*.svg` 경로와 맞췄다. Codex 는 변형된 6-lobe + `>_` 합성 마크 대신 공식 path 를 사용한다.
- Stream Deck+ Device Preview 의 `SessionSlotView` watermark 를 원형 initial placeholder 에서 공용 `SessionCreatureIcon` asset renderer 로 교체했다.
- Swift D200H renderer 의 brand CGPath 를 공식 Claude/Codex/OpenClaw path 로 갱신하고 renderer revision 을 올려 cached PNG manifest 를 무효화했다.
- Stream Deck session detail 진입 시 선택 세션의 list-state 로 먼저 prime 하고, relay 가 도착하면 해당 sessionId 와 일치하는 state 만 상세 화면에 반영한다.
- 상세 화면 명령(select option/send prompt/model/STOP/ESC)은 managed non-OpenClaw session 에 대해 `session_command(sessionId, command)` 로 감싸서 focus race 를 피한다.
- `docs/v4-layout.md` 에 Stream Deck / Stream Deck+ / D200H 별 Agent Session 사용자 시나리오와 버튼 배치 원칙을 정리했다.

### 검증

- `pnpm --filter @agentdeck/shared typecheck` 성공
- `pnpm --filter @agentdeck/shared build` 성공
- `pnpm --filter @agentdeck/plugin typecheck` 성공
- `pnpm vitest run plugin/src/__tests__/renderer-snapshots.test.ts -u` 성공 (1 file / 55 tests, official icon snapshots 갱신)
- `git diff --check` 성공
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataSessionIcons CODE_SIGNING_ALLOWED=NO` 성공 (기존 generated `GatewayFrame.JSONNull.hashValue` deprecation warning 은 남음)

### 배포 메모

- Stream Deck 플러그인 패키지와 Android APK, Apple iOS/macOS archive/export 산출물을 로컬 생성했다.
- `@agentdeck/bridge@0.2.0` 이 runtime 에서 `@agentdeck/hooks` 를 import 해 published install 이 깨질 수 있어, bridge 에 hook migration helper 를 로컬 포함하고 `@agentdeck/bridge@0.2.1` 로 재배포한다.
- `@agentdeck/shared` npm tarball 에 테스트 산출물이 포함되지 않도록 `files` exclude 를 보강했다.
- npm: `@agentdeck/shared@0.2.0`, `@agentdeck/setup@0.2.0`, `@agentdeck/bridge@0.2.2` publish 완료. `@agentdeck/bridge@0.2.2` 설치 smoke test 성공.
- 로컬 CLI: `/usr/local/bin/agentdeck` 를 `@agentdeck/bridge@0.2.2` 로 갱신했고 `agentdeck --version` 이 `0.2.2` 를 반환한다.
- Android: `dist/agentdeck-v0.4.1.apk` 생성 후 연결된 ADB 기기 3대에 설치 완료.
- Apple: `dist/agentdeck-ios-v1.0.1.ipa`, `dist/export_macos/AgentDeck.pkg` export 완료. ASC issuer 환경변수가 없어 TestFlight/App Store Connect 업로드는 로컬에서 수행하지 못함.

---

## 2026-04-28 — Codex observation stale active state 정리

### 문제

Codex lifecycle hook / OTel 로 합성된 세션이 실제 활성 상태가 아닌데도 메뉴바와 D200H 에 `WORKING` 처럼 남는 현상이 있었다. `/status.sessions` 는 registry 기반이라 daemon 하나만 보이지만, D200H 는 `sessions_list` 캐시를 렌더해 서로 다른 상태처럼 보였다.

### 해결

- Codex 합성 세션(`codex:<thread-id>`)이 `processing` 이면서 `currentTool` 이 없는 상태로 30초 이상 새 progress 를 받지 않으면 자동으로 `idle` 로 강등한다. `codex_stop` / `codex_turn_complete` / OTel `turnEnd` 누락 시에도 메뉴바 아이콘과 디바이스가 stale active 상태를 오래 유지하지 않는다.
- TTL 로 제거된 Codex row 는 late `codex_tool_end`, `codex_stop`, notify completion 이벤트로 재생성하지 않는다. 재생성은 `codex_session_start` 또는 `codex_user_prompt_submit` 만 허용 (2026-05-01 갱신: `codex_tool_start` 부활 제거 — leftover hook 으로 phantom 재합성 사례. `codex_user_prompt_submit` 은 인터랙티브 Codex 가 post-terminal TTL 로 evict 된 뒤 다음 프롬프트로 복귀하는 경로라 유지). 자세한 배경은 본 로그의 2026-05-01 항목 참조.
- `/status` 는 registry-only `sessions` 대신 실제 디바이스/대시보드가 받는 `sessions_list` 기반 세션을 반환하고, 기존 파일 registry 값은 `registrySessions` 로 별도 노출한다.

### 검증

- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCodexStaleFix CODE_SIGNING_ALLOWED=NO` 성공

---

## 2026-04-25 → 27 — App Store 앱에 Codex Observation 추가 (lifecycle hooks + notify + OTel)

### 문제

App Store macOS 빌드는 `~/.claude/settings.json` hook 으로 Claude Code 만 dashboard 에 표시했고, Codex 세션은 보이지 않았다. `apple/APP_REVIEW_NOTES.md` 도 "Codex doesn't ship Claude-Code-compatible hooks today" 라고 인정하고 회피해 왔다. codex 0.123.0 이 두 신호 채널 (`notify` config + `[otel]` HTTP exporter) 을 제공하기 시작하면서 다시 가능성이 열림.

### 해결 (3 단계 진화)

**v1 — 초기 구현**: `~/.codex/config.toml` 에 `notify = ["sh", "-c", "<snippet>", "agentdeck-notify"]` + `[otel] exporter = "otlp-http"` 주입. `[features] codex_hooks` 모름. 세 신호원이 `pushedSessionsById` 의 `codex:<thread-id>` 키로 수렴. 신규 파일: `MiniToml`, `CodexConfigInstaller`, `CodexOtelRoutes`, `CodexTelemetryModule` + tests. 기존 `/hooks/*` 라우트 + `handleHookEvent` 에 codex_* event case 들 추가.

**v2 — Codex review 대응**: 외부 review 에서 P1×2 + P2×1 발견. (1) OTel schema 가 `[otel.trace_exporter.otlp-http]` sub-table 에 `protocol = "json"` 이라야 함, top-level `[otel]` 아님. (2) Codex notify argv 가 array 끝에 JSON 을 append → `sh -c "<s>" <json>` 시 `$0=json` 이라 우리 snippet 의 `$1` 비어있음 → dummy `agentdeck-notify` 4번째 element 추가. (3) daemon 의 dynamic port (9120 → fallback) 와 install 시점의 hardcoded endpoint 가 어긋남 → daemon 시작 시 `step11b CodexConfigInstaller.installIfNeeded()` 호출로 매번 재기록.

**v3 — 환경 fix + 외부 추가 진단 적용**: 라이브 검증 시도 중 발견된 layered 문제들.
- **HTTPServer body framing bug** (외부 진단 핵심): `bodyBytesSoFar < expectedBody && isComplete` 분기가 partial body 를 통과시켜 truncated JSON 이 parser 에 흘러 매번 fail. multi-chunk `receiveFullRequest` + raw-Data body slice (utf-8 round-trip 회피) + Content-Length 미충족 + isComplete 시 reject 로 fix.
- **CodexConfigInstaller 재설계** (Codex 가 인계 받음): `[features] codex_hooks = true` + `[[hooks.SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/Stop]]` 5 종 lifecycle hook 표 + stdin payload (`-d @-`). notify/OTel 은 사용자 키 충돌 시 자동 omit 되는 fallback 으로 격하. `MiniToml.hasTableOutsideFence` 가 array-of-table `[[hooks.Stop]]` 충돌도 검출.
- **OTel parser** 가 codex 실 emit (`session.task.turn`, `op.dispatch.user.input.with.turn.context`, `tool.call`, `tool.result`) 도 수용 + `spanNameSummary` 진단.
- **macOS 26 환경 hang fix 두 개**: ① `AuthManager.loadOrCreateToken` — sandbox 첫 launch 시 Group Container `Data(contentsOf:)` 가 `__open` syscall 에서 silent block (sandboxd deny log 도 안 찍힘) → background queue + 2s timeout. ② `SettingsScreen.servicesContent` — SwiftUI Settings scene 의 view tree 평가만으로 keychain `SecKeychainItemCopyContent` 가 main thread block (ad-hoc 빌드의 ACL prompt 반복) → `.task` + `Task.detached` 분리.

### 핵심 설계 결정

- **fence-block lossless TOML 편집기** (`MiniToml`): 사용자 키 / 코멘트 / 순서 byte-for-byte 보존. `# >>> AgentDeck managed (do not edit) <<<` sentinel 로 자기 라인 식별. dictionary roundtrip 금지.
- **두 신호원이 같은 sessionId 키 공유**: `codex:<thread-id>`. notify 단독, OTel 단독, 둘 다 — 셋 다 정상. `updateSessionHookState` 가 idempotent.
- **App Store 가드레일 보존**: subprocess spawn 0건, companion-install 강요 copy 0건, home-relative-path entitlement 0건. `verify-appstore-archive.sh` 의 `^/bin/sh$` regex 회피하려고 notify array 의 shell path 는 `"sh"` (PATH lookup), 절대 경로 아님.
- **환경 fix 일반화**: 모든 main-actor sync file/keychain I/O 는 background queue + timeout 으로. 새 lesson 메모 두 개로 고정 (memory: `macos26-sandbox-first-launch-open-block`, `swiftui-settings-keychain-onappear-block`).

### 검증

- **정적 게이트**: Release 빌드 ✅ + `verify-appstore-archive.sh` ✅ (모든 v1+v2+v3 + 환경 fix 적용 후)
- **Xcode Run / 직접 launch 둘 다 정상 startup** — AuthManager fix 이후. 사용자 GUI freeze 해소 확인.
- **라이브 wire 검증은 보류**: ad-hoc 빌드의 환경 hang + codex 의 실제 schema 검증 필요. 정식 서명 빌드 (TestFlight) 사용 시점에 별도 진행. plan 파일 (`/Users/puritysb/.claude/plans/cli-app-store-modular-moonbeam.md`) 의 "Live Verification" 절 참조.

### 미해결 / 후속

- 라이브 wire 검증 (TestFlight 빌드 사용 시점)
- fence 안 사용자 sub-table 침입 보존 로직 (`[tui.model_availability_nux]` 류 — 우리 fence body 가 다음 install 때 잡아먹음)
- TopologyRail hub spine vertical-stretch (별도 commit 으로 fix 완료: `cef3283a`)
- Onboarding 의 Choose Agent ↔ Optional Integrations 항목 중복
- iPhone/iPad 페어링 화면 시각화 개선

---

## 2026-04-26 — macOS Dashboard auto-connect blocked by daemon readiness

### 문제

Dashboard 는 macOS foreground 복귀 시 daemon ready 신호가 오기 전까지 mDNS waterfall 을 건너뛴다. 그런데 daemon 은 9120 listener 를 먼저 열어도 optional startup 작업들이 끝나기 전에는 `DaemonService.onReady` 를 호출하지 않았다. 이 상태에서 App Group 컨테이너 파일 I/O 또는 HID 진단이 hang 되면 `/health` 는 connection refused 또는 no response 로 보이고, Dashboard 는 자동 localhost 연결을 시작하지 못했다.

샘플에서 확인된 blocker:

- D200H startup diagnostic 의 `IOHIDManagerCopyDevices`
- D200H 분석용 ZIP dump 의 App Group 파일 write/rename
- Pixoo `settings.json`, WiFi `wifi-config.json`, APME/MLX settings, usage cache 의 동기 `Data(contentsOf:)`

### 해결

- D200H HID enumeration diagnostic 을 background/timeout 처리로 바꿔 module startup 을 막지 않게 했다.
- D200H 분석용 ZIP dump 를 best-effort background write 로 전환했다.
- Pixoo/WiFi/APME settings 와 usage cache reads 를 bounded background I/O 로 바꿔 timeout 시 기본값 또는 cache miss 로 진행하게 했다.
- Usage cache write 는 startup/connect path 를 막지 않도록 background write 로 전환했다.

### 검증

- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataAutoconnectFix CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataAutoconnectFixSigned` 성공
- signed Debug 앱 실행 후 `http://127.0.0.1:9120/health` 가 `status: ok` 반환
- 같은 프로세스에서 `127.0.0.1:<client> -> 127.0.0.1:9120` localhost WS 연결이 `ESTABLISHED` 로 확인됨

---

## 2026-04-26 — macOS Dashboard launch freeze: App Group I/O timeout guard

### 문제

Xcode 에서 macOS 앱을 실행하면 Dashboard 창이 복원된 뒤 앱이 멈춘 것처럼 보였다. `sample` 결과 Dashboard 렌더링이 아니라 daemon startup 경로가 main actor 에서 막혀 있었다.

- macOS state restoration 이 `savedIdentifier=dashboard` 창을 복원해 Dashboard 가 먼저 보였다.
- `DaemonService.start()` → `DaemonServer.init()` → `AuthManager.loadOrCreateToken()` 경로에서 App Group 컨테이너 파일 I/O가 동기 실행됐다.
- `auth-token` read timeout 이후 error logging 이 다시 `swift-daemon.log` 를 동기 open 하면서 main thread 가 `open()` syscall 에서 멈췄다.
- 같은 Group Container 의 `auth-token`, `swift-daemon.log`, `daemon.json`, `sessions.json`, `apme.sqlite` 는 shell 에서도 open 이 hang 될 수 있는 상태였다.

### 해결

- Logger file write/read 를 best-effort background I/O 로 변경하고, 첫 file write 가 hang 되면 추가 file-log write 를 drop 하도록 guard 를 추가했다. `os.Logger` logging 은 유지된다.
- Auth token, daemon/session registry, timeline load/save 를 bounded/background I/O 로 바꿔 startup main path 를 막지 않게 했다.
- APME SQLite open 에 timeout guard 를 추가했다. DB open 이 hang 되면 APME store 는 해당 launch 에서 skip 될 수 있지만 daemon 과 UI startup 은 계속 진행된다.

### 검증

- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataLoggerFreezeFix CODE_SIGNING_ALLOWED=NO` 성공
- signed Debug build 를 sandbox/App Group entitlements 로 실행 후 `http://127.0.0.1:9120/health` 가 `status: ok` 반환
- 검증용 앱 종료 후 9120 listener 없음

---

## 2026-04-26 — CLI passive observer: abtop 참고 개선 + App Store 경계 유지

### 문제

abtop 은 별도 daemon registration 없이도 이미 실행 중인 Claude/Codex 프로세스와 transcript/rollout JSONL 을 읽어 TUI 에 표시한다. AgentDeck 은 관리 세션(`sessions.json`) 중심이라 CLI 밖에서 시작된 세션은 놓치지만, 같은 방식을 App Store macOS 앱에 그대로 넣으면 `ps`/`lsof`/`/proc` 관찰과 외부 프로세스 열람이 App Review 2.5.2/4.2 경계를 흐린다.

### 해결

- `docs/appstore-feature-matrix.md` 에 `외부에서 이미 실행 중인 Claude/Codex 세션 passive discovery` 를 CLI-only 로 먼저 분류했다.
- Node daemon 전용 `PassiveSessionObserver` 를 추가했다.
  - Claude: `~/.claude/sessions/<pid>.json` + project transcript tail 을 읽어 model/state/current task/context/token 을 best-effort 로 요약한다.
  - Codex: `ps` + macOS `lsof -F pn` 또는 Linux `/proc/<pid>/fd` 로 rollout JSONL 을 찾아 session metadata/token/function call state 를 요약한다.
  - tool argument 는 짧게 표시하고 API token/Bearer/GitHub/Slack 계열 secret 은 redaction 한다.
  - AgentDeck 이 직접 관리하는 bridge child process 는 pid/ppid 기반으로 중복 제거한다.
- daemon `sessions_list` 에 관측 세션을 `controlMode: "observed"`, `port: 0` 으로 섞어 보낸다. TUI 는 observed 라벨과 current task 를 표시하되 숫자 hotkey/focus 대상에서는 제외한다.
- D200H 버튼 focus 목록에서도 `observed` / `port: 0` 세션을 제외해 제한된 하드웨어 슬롯이 제어 가능한 세션만 대상으로 삼도록 했다.
- shared protocol 의 optional session metadata 를 확장했고, 기존 `ClientRegisterCommand` block comment 를 generator 가 필드로 읽지 못하던 문제를 고쳐 generated command builders 를 source-of-truth 와 맞췄다.

### App Store 경계

- 새 passive discovery 는 `bridge/src/passive-observer.ts` 와 Node daemon 연결부에만 존재한다.
- `apple/` App Store 앱 소스에는 새 subprocess, shell, external CLI 호출 경로를 추가하지 않았다.
- App Store 단독 앱은 기존처럼 Claude hook / Codex lifecycle hooks 로 opt-in 된 세션만 표시하며, CLI companion 이 없을 때도 결함 UI 를 노출하지 않는 설계를 유지한다.

### 검증

- `pnpm --filter @agentdeck/shared typecheck` 성공
- `pnpm --filter @agentdeck/bridge typecheck` 성공
- `pnpm vitest run bridge/src/__tests__/passive-observer.test.ts bridge/src/__tests__/session-aggregator.test.ts bridge/src/__tests__/bridge-core-sessions.test.ts bridge/src/__tests__/tui-dashboard.test.ts bridge/src/__tests__/tui-renderer-snapshots.test.ts` 성공 (5 files / 49 tests)
- `git diff --check` 성공

---

## 2026-04-26 — Codex observation: HTTP body framing fix + lifecycle hooks primary

### 문제

Codex OTel POST 진단에서 `Content-Length` 는 큰 값인데 daemon 이 65 KB 안팎의 partial body 를 JSON parser 로 넘기고 있었다. 기존 `HTTPServer.receiveFullRequest` 는 `isComplete == true` 를 "요청 body 완료"로 해석해 `bodyBytesSoFar < Content-Length` 상태도 통과시켰고, 그 결과 OTel JSON 은 항상 prefix만 들어와 parse 불가였다.

동시에 Codex observation 의 큰 설계도 notify + OTel 중심이라 불안정했다. 현재 Codex 공식 docs 에는 `[features] codex_hooks = true` + inline `[[hooks.*]]` lifecycle hook 이 있고, command hook 이 stdin 으로 JSON payload 를 받는 경로가 존재한다.

### 해결

- `HTTPServer.receiveFullRequest` 에서 `Content-Length` 미충족 + `isComplete` 인 경우 partial request 를 reject 하도록 변경. body extraction 도 header string 재조립이 아니라 raw `Data` slice 로 유지.
- `CodexConfigInstaller` 를 lifecycle hooks primary 로 재구성:
  - `[features] codex_hooks = true`
  - `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` inline hook tables
  - hook command 는 stdin JSON 을 그대로 `/hooks/codex_session_start`, `/hooks/codex_user_prompt_submit`, `/hooks/codex_tool_start`, `/hooks/codex_tool_end`, `/hooks/codex_stop` 로 POST
  - user `notify` / `[otel]` 이 없을 때만 optional fallback/exporter 유지
- user-authored `[features]` / `[hooks]` 는 unsafe merge 하지 않고 설치 abort. `MiniToml.hasTableOutsideFence` 는 `[[hooks.Stop]]` 같은 array-of-table 도 conflict 로 잡도록 보강.
- daemon hook router 에 `codex_*` 이벤트를 추가하고 Codex session id 를 `codex:<session/thread>` 로 namespace 해서 Claude 세션과 충돌하지 않게 했다. Codex 이벤트는 APME Claude hook collector 로 흘리지 않는다.
- OTel parser 는 실제 관찰된 `op.dispatch.user_input_with_turn_context`, `session_task.turn`, `thread.id`, `turn.id` 계열도 best-effort 로 인식하고, unknown span batch 는 span name summary 를 로그에 남긴다.
- App Store feature matrix / review notes 를 lifecycle hooks primary + notify/OTel fallback 설명으로 갱신.

### 검증

- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCodexHooksBuild CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild build-for-testing -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCodexHooksBFT CODE_SIGNING_ALLOWED=NO` 성공
- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Release -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCodexHooksRelease CODE_SIGNING_ALLOWED=NO` 성공
- `bash apple/scripts/verify-appstore-archive.sh /tmp/AgentDeckDerivedDataCodexHooksRelease/Build/Products/Release/AgentDeck.app` 성공
- 실제 `xcodebuild test -only-testing:...` 는 host app XCTest launch 단계에서 출력 없이 멈춰 중단. 테스트 bundle compile 은 `build-for-testing` 으로 확인.

---

## 2026-04-24 — Xcode 26.4 recommended settings + Swift 6 warning cleanup

### 문제

Xcode 26.4.1 환경에서 Apple 타깃을 열면 두 축의 경고가 동시에 발생했다.

1. **Update to recommended settings** — `apple/AgentDeck.xcodeproj` 가 여전히 `LastUpgradeCheck = 1600` 상태라 현재 Xcode 버전 기준 추천 설정 배지가 남음.
2. **Swift 6 actor/deprecation warnings** — `AppPreferences`, `HookInstaller`, `ESP32ProvisionSheet`, `ESP32Serial`, `GatewayFrame`, `DevicePreviewScreen` 중심으로 main actor 격리, deprecated AppKit API, legacy `Hashable.hashValue`, 불필요한 `nonisolated(unsafe)` 경고가 누적. 실제 빌드를 돌려 보니 `QRScannerView`, `DisplaySyncService`, `AuthManager`, `D200hHidModule`, `CloudCreature` 에도 추가 경고가 surfaced 됨.

### 해결

- `apple/project.yml` 의 `xcodeVersion` 을 `16.0` → `26.4` 로 올리고 `xcodegen generate` 로 `project.pbxproj` 재생성. 결과적으로 `LastUpgradeCheck = 2640` 으로 갱신돼 추천 설정 경고 제거.
- `AppPreferences.chooseAntigravityDatabase()` 를 `@MainActor` 로 고정하고 `NSOpenPanel.allowedFileTypes` 를 `allowedContentTypes` 기반 UTType 해석으로 교체. `shared` singleton 의 `nonisolated(unsafe)` 도 제거.
- `HookInstaller.promptAndInstall()` 의 JSON 파일 picker 도 `allowedContentTypes = [.json]` 으로 교체.
- `ESP32ProvisionSheet` 는 GCD background closure 대신 `Task.detached` + main-actor 복귀 패턴으로 재작성. 포트 탐색/serial write helper 를 `nonisolated` static 으로 내려 Swift 6 concurrent-capture 경고 제거.
- `ESP32Serial` 의 `NSLock` 상수에서 불필요한 `nonisolated(unsafe)` 제거.
- generated `GatewayFrame.JSONNull` 에 `hash(into:)` 구현 추가.
- `DevicePreviewScreen` 의 deprecated `.onChange(of:perform:)` 클로저를 2-parameter 시그니처로 전환.
- 빌드 중 추가로 surfaced 된 경고도 함께 정리:
  - `QRScannerView` → `@preconcurrency import AVFoundation`
  - `DisplaySyncService` → `UIScreen.main.brightness` 접근을 main actor helper 로 수렴
  - `AuthManager` → `String(cString:)` deprecated 경로를 UTF-8 decode helper 로 교체
  - `D200hHidModule` → `CFRunLoop` capture 를 local var 대신 `RunLoopBox` reference 로 전달
  - `CloudCreature` → 미사용 bounding-rect locals 삭제

### 검증

- `xcodegen generate` 성공
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataRecommendedSettings build CODE_SIGNING_ALLOWED=NO` 성공
- XcodeBuildMCP `build_sim` for `AgentDeck_iOS` 성공
- 남은 출력은 소스 경고가 아니라 Xcode build-system note 두 개뿐:
  - `App Store Helper Guard` script phase always-runs note
  - `appintentsmetadataprocessor` 의 "No AppIntents.framework dependency found" informational warning

---

## 2026-04-24 — Settings/Preview 창 크롬 일관화 + Integrations 브랜드 아이콘

### 문제

사용자가 스크린샷으로 세 가지를 차례로 지적:

1. **메뉴바 팝업의 Claude 상태가 "Not connected" 로 표시** — Dashboard 는 녹색인데 메뉴바만 경고. App Store sandbox 에서 `~/.claude/` OAuth 토큰을 못 읽어 `oauthConnected == false` 가 고정인데, `MenuBarTopologyList.claudeStatus` 는 oauth 단독 게이트라 hook 으로 잘 흘러오는 세션도 warn 으로 잡음. Dashboard `TopologyRail` 와 Settings `IntegrationStatusEvaluator` 는 이미 `hooks || oauth` 로 수정돼 있었으나 메뉴바만 drift.
2. **Settings, Device Preview, Launch Session, Pair iPad 팝업들의 룩앤필이 ControlTowerPanel 과 따로 놀음** — 사이드바 material 이 밝은 system vibrancy, 타이틀바가 본문 다크와 이질적.
3. **Settings 창만 유독 이상** — 사이드바 토글이 타이틀바로 못 올라가고 "Essentials" 위에 드리프트, Advanced 섹션은 기본 접힘, Integrations 아이콘이 SF Symbols (`bolt.fill`, `person.badge.key` 등) 라 "서비스 느낌" 없음.

### 해결

**1. 메뉴바 Claude 상태 정합**
- `apple/AgentDeck/UI/Settings/IntegrationsView.swift` 에 `ProviderRailEvaluator` 신설 (`claude`, `openClaw` 두 정적 메서드). `(LEDStatus, subtitle)` 페어 반환. `hooks || oauth` formula 단일화 + OpenClaw 의 `gatewayAuthStatus` 매핑 (`reconnecting`/`device_auth_invalid`/`gateway_reachable` 추가) 을 한 군데로 모음.
- `TopologyRail.claudeRow / openClawRow` 와 `MenuBarTopologyList.claudeRow / openClawRow` 가 동일 evaluator 호출. rail-only 서피스 (model catalog, rate chips, consumer creature dots) 는 local 유지.
- `MenuBarTopologyList` 에 `@EnvironmentObject preferences` 추가, 기존 로컬 `RailStatus` enum 은 삭제하고 공용 `LEDStatus` 로 통합. `LEDStatus.isFilled` 계산 프로퍼티를 `TopologyRail.swift` 에 추가해 메뉴바의 filled/outline 구분 유지.
- `apple/AgentDeckTests/ProviderRailEvaluatorTests.swift` 17 case — Claude hooks-only / oauth-only / both / neither × OpenClaw 각 auth status → LED/subtitle 매핑 회귀 가드.

**2. 팝업 창 아쿠아리움 표면 통일**
- `apple/AgentDeck/UI/Shared/AquariumSurface.swift` 신설. `aquariumGradient` (deepSea→midWater) 상수 + `AquariumSurfaceModifier` (gradient background + `.foregroundStyle(TerrariumHUD.text)` + `.preferredColorScheme(.dark)`). 서브 modifier `WindowTitlebarBackground` 는 `if #available(macOS 15, *) { .containerBackground(aquariumGradient, for: .window) }` 로 macOS 15+ 에서 타이틀바까지 확장. macOS 14 은 no-op fallback.
- `HUDFont` (sectionHeader/title/body/caption/mono/monoSmall) + `HUDSectionHeader` 헬퍼 노출.
- `LaunchSessionDialog`, `QRPairingWindow`, `ESP32ProvisionSheet`, `PixooSheet`, `DevicePreviewScreen` 모두 `.aquariumSurface()` 적용. DevicePreview NavigationSplitView sidebar 는 `.scrollContentBackground(.hidden)` + 각 row `.listRowBackground(Color.clear)` 로 system sidebar vibrancy 를 뚫음.

**3. Settings scene → Window 마이그레이션**
- SwiftUI `Settings {}` 는 toolbar band 를 안 그려서 NavigationSplitView auto sidebar-toggle 이 sidebar 헤더로 내려옴 (사용자가 "이상하게 붙어있다" 라고 지적한 그 증상). `apple/AgentDeck/App/AgentDeckApp.swift` 에서 `Window("AgentDeck Settings", id: "settings")` + `.commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { openWindow(id: "settings") }.keyboardShortcut(",", modifiers: .command) } }` 로 전환. cmd+, 와 `App → Settings…` 메뉴는 명시 연결.
- 4곳 `SettingsLink` 를 `Button { openWindow(id: "settings") }` 로 교체: `ControlTowerPanel` (rateLimitsEmptyState + settingsPillButton), `SetupNeededCard.openSettingsButton`, `MonitorScreen.settingsGearButton` + `openSettings()`. `#available(macOS 14.0, *)` availability 분기 + `showPreferencesWindow:` / `showSettingsWindow:` selector fallback 일괄 제거.
- `SettingsScreen.macOSSettings` 에 `.aquariumSurface()` 추가, `advancedExpanded = true` 기본값으로 변경 (사용자 요청). sidebar List 동일한 `.scrollContentBackground(.hidden)` + `.listRowBackground(Color.clear)` 적용.

**4. Integrations 브랜드 아이콘**
- `IntegrationDescriptor` 에 `iconAssetName: String?` + `iconTint: Color?` 선택 필드 추가, 생성자는 default 값으로 호환. `IntegrationRow` 에 `integrationIcon(size:fallbackTint:overrideTint:)` 헬퍼 신설 — asset 있으면 `Image(assetName).renderingMode(.template)` + tint, 없으면 SF Symbol fallback. settings / onboarding / setupCard 세 body 모두 이 헬퍼 사용.
- Claude Code → `CreatureClaudeCode` + `TerrariumHUD.claudeBody` (terracotta)
- Codex (ChatGPT) → 처음엔 `CreatureCodex` 로 바꿨다가 사용자 요청으로 `BrandOpenAI` 로 재변경. `assets/logos/openai.svg` 를 `apple/AgentDeck/Resources/Assets.xcassets/BrandOpenAI.imageset/` 으로 번들 + template rendering intent. tint 는 near-white.
- OpenClaw Gateway → `CreatureOpenClaw` + crayfish red
- Anthropic Admin API → `assets/logos/claude.svg` 를 `BrandAnthropic.imageset/` 으로 번들 + claude body tint
- Antigravity → 공식 브랜드 SVG 가 번들 가능 상태가 아니라 `atom` SF Symbol 유지

**5. ESP32 / Ulanzi TC001 프리뷰 실제 firmware 시각 시그니처 반영**
- `apple/AgentDeck/UI/Preview/Devices/EinkEsp32Previews.swift` 의 ESP32 4종 (86Box / IPS 3.5" Landscape / Portrait / Round AMOLED 1.6") 을 공용 `Esp32TerrariumScene` 로 재작성. deepSea→midWater→shallowWater 수중 그라데이션 + 수면 caustic 하이라이트 + 배경 swimming creature + 하단 HUD bar (AgentDeck 로고 + tetra-neon 언더라인 + 세션 라인 + 5h/7d water-fill 게이지) — `esp32/src/ui/widgets/hud_bar.cpp` 의 `createGauge` 시각 레이아웃 복제. 기존의 큰 중앙 크리처 / watch tick 12개 (Round) / "AGENTDECK" 헤더 (Portrait) 삭제.
- `apple/AgentDeck/UI/Preview/Devices/MatrixTerminalPreviews.swift` 의 `UlanziMatrixPreview` 는 기존 "Pixoo 렌더러 crop" 대신 실제 `matrix_pages.cpp` 의 AGENTS 페이지 재현 — 8×32 LED 을 Canvas 로 직접 그림. 5×6 sprite (`SPR_OCTOPUS`/`SPR_OPENCODE`/`SPR_JELLYFISH` 비트마스크 그대로 포팅) + 3×5 마이크로폰트 "Nx" 라벨 + 우측 column 의 세션별 state LED.
- `EinkEsp32Previews` 의 E-ink Mono/Color 와 `WearableTabletPreviews` 의 Apple Watch / iPad / Android Tablet 도 같은 아쿠아리움 그라데이션 + GeometryReader 로 배경 creature 배치로 폴리싱 (session 후반 사용자 hand edit 포함).

**6. Antigravity 데이터베이스 피커 기본 경로**
- `apple/AgentDeck/App/AppPreferences.swift` 에 `defaultAntigravityDirectoryURL()` 추가. NSOpenPanel 열 때 `~/Library/Application Support/Antigravity/User/globalStorage` 등을 순차 검사해서 첫 존재하는 디렉토리로 자동 이동 + `showsHiddenFiles = true`.

### 핵심 설계 결정

- **`ProviderRailEvaluator` 와 `IntegrationStatusEvaluator` 는 별개 evaluator** — Settings 의 `IntegrationStatus` 는 5-way (`connected/awaiting/failed/notConfigured/unsupported`), rail 은 4-way (`.ok/.warn/.error/.dim`) + compact subtitle. 억지로 합치면 호출부가 복잡해져서 각 용도별로 나눠둠. Non-Goals 로 명시.
- **`Settings {}` 대신 `Window(id: "settings")`** — macOS 표준 Preferences 창의 이점 (System Settings 와 일관된 chrome) 을 포기하는 대신 NavigationSplitView sidebar toggle 을 Device Preview 와 같은 위치에 띄우는 시각 일관성을 택함. cmd+, / `App → Settings…` / `SettingsLink` 대체 경로는 수동 연결. 이 선택이 깨지면 menuBar pill / gear / SetupCard "Open Settings" 버튼도 같이 깨지므로 테스트 때 함께 확인.
- **Integrations 아이콘은 asset-first, SF Symbol fallback** — `iconAssetName` 필드는 optional 로 두고 기존 `iconSystemName` 을 보존. 새 서비스 추가 시 asset 없으면 즉시 SF Symbol 로 돌아가 compile 안 깨짐. template rendering + tint 파라미터로 브랜드 색 제어.
- **ESP32 프리뷰는 firmware 코드를 Swift 포팅하지 않음** — LVGL → SwiftUI 정확 포팅은 scope 이 너무 큼. 대신 "시각 시그니처" (수중 그라데이션 + HUD bar 배치 + 게이지 형태) 만 복제. 사용자가 실제 디바이스 접근 시 "같은 아티팩트" 로 인식되는 수준이 목표.
- **Ulanzi TC001 은 LED 비트맵 정확 포팅** — 스프라이트가 5×6 = 30비트 수준이라 Canvas 로 직접 `setPixel` 흉내내는 게 Pixoo crop 보다 훨씬 정확하고 작업량도 적음. `matrix_pages.cpp` 의 `SPR_OCTOPUS` 등 바이트 배열을 그대로 복사해 사용.
- **타이틀바 통일은 macOS 15 gating** — `.containerBackground(for: .window)` 가 macOS 15+ 라 `#available` 로 감쌌고 14 는 fallback. 배포 타겟은 14 유지. 14 유저가 일부 있다면 타이틀바가 여전히 light vibrancy.

### 후속

- 현재 미커밋 상태 — session-end 시 전체 commit 예정
- `BrandOpenAI`/`BrandAnthropic` 외 `BrandAntigravity` asset 은 공식 SVG 확보 시 추가 (현재 `atom` SF Symbol)
- macOS 14 이하 배포 지원 여부는 미결 — 장차 15 로 끌어올리면 `WindowTitlebarBackground` 의 `#available` 분기 제거 가능

---

## 2026-04-22 — TC001 black screen + Claude Code model/effort surface

### 문제

두 개의 별개 이슈가 같은 세션에서 잡힘:

1. **TC001 LED 매트릭스가 간헐적으로 완전 검정으로 가는 현상** — `matrix_pages.cpp:renderAgents` 의 ealry-return 분기 (`if (openclawAlive) return;`) 가 OpenClaw 만 alive 이고 Gateway 인증이 아직 안 끝난 짧은 구간에서 단 하나의 스프라이트도 안 그리고 빠져나감. 크레이피시 그리는 분기는 `connected && gatewayConn` 으로 게이트되어 있어 `gatewayConnected=false` 인 동안 (페어링 핸드셰이크, 토큰 미스매치, WS 재연결, 또는 부팅 직후 sessions_list 가 gateway 인증보다 먼저 도착하는 레이스) 화면 전체가 `fill_solid(Black)` 상태로 `FastLED.show()` 됨.

2. **Claude Code 가 `/model` 로 max/xhigh/default/fast effort 를 토글해도 모든 surface 에 표시 안 됨** — `bridge/src/output-parser.ts:64` 의 `EFFORT_LEVEL` regex 가 `\b(high|medium|low)\s+effort\b` 로 좁혀져 있어 max/xhigh/default/fast 를 묵묵히 드롭. 사용자가 Stream Deck/Dashboard 어디에서도 자기가 선택한 effort 가 무엇인지 못 봄.

### 해결

**1번 (TC001 검정)** — `renderAgents` 에 fallback 분기 추가: `connected && !gatewayConn && openclawAlive` 일 때 x=27 에 매우 어두운 크레이피시 (`CRGB(12,3,3)`) 를 dormant 신호로 그림. `drewCrayfish` 플래그로 그 사실을 추적하여 `cfX = drewCrayfish ? 27 : 32` agent column 배치 계산에 반영. `if (openclawAlive) return;` early-exit 은 그대로 두지만 위쪽에 fallback 크레이피시가 보장되어 화면이 완전 검정이 되지 않음. 같은 파일 `matrix_display.cpp` 에서는 `currentPage` 부팅 기본값을 `USAGE` → `AGENTS` 로 바꾸고 `hasUsageData()` (`fiveHourPercent >= 0`) + `skipEmpty(Page)` 를 도입해 nextPage/prevPage/auto-cycle 이 빈 USAGE 로 이동하지 않게 게이트 (이전 SD/SD+ active-agent 세션 작업과 함께 commit `56ab762d` 에 묶임).

**2번 (effort 표시)** — regex 를 `\b(max|xhigh|high|medium|low|default|fast)\s+effort\b` 로 확장 (`output-parser.ts:64`) + 신규 케이스 5개 테스트 추가. effort 는 이미 state-machine → state_update event → DashboardState 까지 통과하고 있었는데 정작 PTY 파서가 새 라벨을 못 잡아서 항상 null 이 흘러갔음. effort label 을 `SessionInfo` 에도 추가해 sibling/per-button 까지 흘려보내는 cross-platform 배관: TS (`shared/protocol.ts`, `session-aggregator.ts`, `bridge-core.ts`, `daemon-server.ts`, `daemon-ws-client.ts`, `hook-server.ts`, `index.ts`), Swift (`SessionInfo` struct, `DaemonSessionEntry`, `SessionAggregator.swift`, `DaemonServer.sessionToDict` + `/health` payload, `SessionListPanel`), Kotlin (`Protocol.kt`, `EinkAgentColumn.kt`, `SessionListPanel.kt`, `EinkMonitorScreen.kt`). neutral effort 필터 (`medium` 만 dropping) 를 `medium` + `default` 둘 다로 확장 — Claude Code 2.1+ 이 "default" 를 per-model neutral 로 노출하는 변화 반영. SD 키패드 타일 렌더러 (`shared/svg-renderers/session-slot-renderer.ts`) 에 `formatModelEffort()` 헬퍼 추가해 `Opus 4.7 · max` 를 한 줄로 14char budget 안에 표시.

추가로 D200H 에서 어색하게 정지된 stitch line (flowing-light dashed border 가 animation tick 없이 frozen dashes 로 보이던 문제) 도 같은 commit 에 들어감: `BorderStyle` 에 `.processingSolid(color)` 케이스 추가 → solid glowing amber border 로 대체. shared SVG 렌더러도 `options.animated: false` 일 때 dashes 대신 solid border + `▶ RUN` badge 를 그리도록 분기.

### 핵심 설계 결정

- **`if (openclawAlive) return;` 를 보존하되 위에 fallback 크레이피시를 보장** — 문어를 octopus 로 그리지 말자는 원래 의도(OpenClaw 만 있는데 Claude session 이 있는 것처럼 보이는 거 방지) 는 유지하면서 검정 화면만 막음. early-exit 자체를 없애면 OpenClaw 단독 상태에서 octopus + 크레이피시 둘 다 뜨는 문제 발생.
- **D200H 에 `processingSolid` 신규 케이스 추가, `processingDash` 는 삭제 안 함** — animation 이 동작하는 모드 (`d200hStableStockHidEnabled() == false`) 가 다시 생길 가능성 대비. `computeSessionListSlots` 에서 `isStable ? .processingSolid : .processingDash(animFrame)` 로 분기.
- **effort regex 는 whitelist 유지** — `\b(\S+)\s+effort\b` 같은 광범위한 패턴 안 씀. `1. High effort quality\n` 같은 numbered option line 이 false positive 로 잡히는 걸 막는 기존 테스트 (`output-parser.test.ts:3000`) 가 깨질 위험. 새 라벨이 더 등장하면 그때 추가.
- **TC001 펌웨어는 esptool direct + `--flash-size 8MB`** — PIO upload hang 이슈 (`ulanzi-tc001.md` 메모리) + 4MB 로 플래시되면 부트로더 실패해서 부저 계속 울리는 함정 회피. CH340 포트 식별은 `device_info_request` JSON 응답이 아니라 `esptool.py chip_id` 의 `Chip is ESP32-D0WD` 로 — TC001 의 CH340 TX 가 하드웨어적으로 broken 이라 JSON 응답이 안 돌아옴 (대신 ROM bootloader 는 정상 동작).

### 후속

- npm/플러그인/App Store/Android 정식 배포는 안 함 (사용자가 로컬에서 검증 후 결정 예정)
- TC001 펌웨어는 본 세션에서 직접 esptool 로 플래시 완료 (MAC `24:d7:eb:b1:cd:e4` 확인, app-only @ 0x10000)
- 86 Box (ESP32-S3, port `wchusbserial211340`) 는 같은 펌웨어 빌드 대상이 아니므로 별도 빌드/플래시 불필요
- `shared/src/command-builders.ts` 의 auto-generated `clientRegister()` 가 required `clientType` 필드 누락으로 TS 빌드 깨뜨리는 issue 가 있어 한번 수동 시그니처 수정했으나 사용자가 의도적으로 원본 (auto-gen output) 으로 되돌림 — generator 자체에 문제가 있다는 인지가 있는 것으로 보임. 다음 세션에서 manually fix 시도 금지

---

## 2026-04-22 — projectName resolves to git toplevel basename (monorepo subdir fix)

### 문제

`cd apple && agentdeck claude` 를 하면 Dashboard / plugin / iOS / macOS Terrarium 모든 surface 에서 프로젝트 이름이 `"AgentDeck"` 가 아닌 `"apple"` 로 표시. 원인은 `bridge/src/index.ts:184` 의 `process.cwd().split('/').pop() || 'unknown'` — 단순 cwd basename 이라 monorepo subdir 에서 상위 repo 아이덴티티를 잃음. 이 값은 `sessions.json` 과 `apme.sqlite runs.project_name` 에 각인돼 세션이 끝나기 전에는 스스로 고쳐지지 않음. 같은 버그가 APME collector fallback(`apme/collector.ts:97`) 과 Swift daemon 의 hook payload 처리(`DaemonServer.swift:1568-1574`, `1709-1715`) 두 곳에 복제돼 있어서 `cwd` 를 `lastPathComponent` 로 깎던 Swift 쪽도 동일하게 오염.

### 해결

단일 유틸 `resolveProjectName()` 도입 후 4곳 호출부를 모두 이 유틸로 수렴:

- **`bridge/src/utils/project-name.ts`** (신규) — fallback chain: `AGENTDECK_PROJECT_NAME` env → `git rev-parse --show-toplevel` basename → nearest ancestor `package.json` 의 `name` → `basename(cwd)` → `'unknown'`. git subprocess 패턴은 `apme/collector.ts:562` 의 `readGitHead` 를 그대로 복제(`stdio: ['ignore','pipe','ignore']`, 2s timeout, try/catch swallow). package.json walk 은 nearest-match(아니라 outermost) — monorepo 루트 케이스는 git 단계가 먼저 잡기 때문에 non-git 에서는 cwd-local 의미가 더 맞음.
- **`bridge/src/index.ts:184`** — `resolveProjectName()` 로 교체. **L219 도 같이 수정**: 기존 `adapter.getProjectName() || projectName` 에서 banner-parse 가 `output-parser.ts:694` 의 `/[~\/][\w.\-\/]+\/(\w[\w.\-]*)\s*$/m` 로 **마지막 path segment** 만 잡아 또 `"apple"` 을 반환하기 때문에 resolver 값을 덮어씀. 따라서 banner 는 버리고 resolver 단독 authoritative 로 전환.
- **`bridge/src/apme/collector.ts:97`** — `basename(input.projectPath)` fallback 을 `resolveProjectName({ cwd: input.projectPath })` 로 교체.
- **`apple/AgentDeck/Daemon/Core/ProjectNameResolver.swift`** (신규) — Foundation 전용(Process() 없음)으로 같은 계약. `.git` marker 탐색은 **dir 또는 file 양쪽 다 인정**(submodule/worktree layout). `package.json` 은 `FileManager.contents` + `JSONSerialization` 으로 파싱. cwd 가 sandbox 바깥이면 `fileExists` 가 false 반환 → 기존 `lastPathComponent` 경로로 graceful fallback, 무회귀.
- **`DaemonServer.swift`** — session_start inline 클로저(1568-1574) + `projectNameFromHookPayload` private method(1709-1715) 를 모두 `ProjectNameResolver.projectName(fromHookPayload:)` 로 교체. private method 는 삭제.

### 핵심 설계 결정

- **Stale data 는 migrate 안 함** (유저 명시적 요구): 기존 `sessions.json` 의 `"apple"` 은 라이브 세션 종료 시 PID prune 으로 자연 삭제. `apme.sqlite runs.project_name` 의 과거 행은 불변 유지. session-registry 에는 원래 `cwd` 가 저장돼 있지 않아 recompute-on-read 자체가 불가능. 새 세션부터 올바른 이름.
- **Swift 는 subprocess-free**: `AGENTDECK_APP_STORE` 컴파일 가드 불필요. 한 코드 경로로 dev + App Store 둘 다 커버. `verify-appstore-archive.sh` 의 forbidden-string 리스트(`Process()`, `/bin/sh`, `osascript`, `security`, `sqlite3`, `adb`, …)에 걸리지 않음.
- **`AGENTDECK_PROJECT_NAME` escape hatch 추가**: 한 줄 비용으로 "이 세션만 다른 이름" 요구 해결. 기존 `AGENTDECK_DATA_DIR` precedent 따름.
- **Banner parser dethrone**: `output-parser.ts:694` 가 `cwd` 마지막 segment 만 잡는다는 사실을 놓쳤다면 resolver 가 맞게 계산해도 L219 에서 여전히 `"apple"` 로 뒤덮였을 것. 앞으로 projectName 관련 수정 시 adapter.getProjectName() 도 같은 한계가 있다는 점을 잊지 말 것.

### 검증

- `pnpm test` 981/981 (14개 신규 `project-name.test.ts` + 기존 `state-machine.test.ts:330` 의 `'AgentDeck'` hardcoded assertion 포함 모두 그린)
- `pnpm --filter @agentdeck/bridge typecheck` clean
- `xcodebuild build-for-testing AgentDeck_macOS` **SUCCEEDED** (Swift compile clean; `xcodegen` 으로 `ProjectNameResolver.{swift,Tests.swift}` 자동 인식)
- `xcodebuild test` 는 본 머신에서 "test runner hung before establishing connection" 으로 타임아웃 — **내 변경과 무관한 기존 환경 이슈**. 동일 증상이 unmodified `SessionLauncherTests` 에서도 재현되고, memory 노트 `xctest-zombie-blocker.md` 의 "sudo kill or reboot" 케이스와 정확히 일치. `testmanagerd` 재시작만으로는 해소 안 됐고 리부트 후 재실행 필요.

---

## 2026-04-22 — Swift daemon: APME task-unit evaluation mirror (App Store 경로)

### 문제

TS bridge 가 직전 커밋(`56ab762d`)에서 task-level eval 을 붙였지만 Swift daemon 엔 없어서 **App Store 앱만 돌리는 사용자(= CLI 미설치)는 새 기능을 못 받는 상태**. 사용자의 원래 질문은 "Claude Code recap 기능을 hook 으로 잡아 task-unit eval 하고 싶다"였고 조사 끝에:

1. recap 은 hook event 로 노출 안 됨 (공식 문서에 없음)
2. recap 자체가 task 완료 신호 아님 (75분 resume / 수동 `/recap`)
3. App Store sandbox 는 `~/.claude/projects/*.jsonl` transcript 까지 차단 (`claude-transcript-reader.ts:13-16` 주석)

→ recap 대신 **hook payload 만으로 자동 감지 가능한 `TodoWrite` all-completed + `/clear` + `session_end`** 를 task 경계 신호로 삼고, judge 가 task 요약을 직접 생성하는 쪽으로 선회.

### 해결

`bridge/src/apme/*` 와 동일한 계약을 Swift daemon 에 포팅:

- **`ApmeStore.swift`** — `tasks` 테이블 + `turns.task_id` / `evals.task_id` 컬럼 + migration. `task_rollup` rubric seed(axes: completion/coherence/efficiency + summary 요청). `ApmeTask` struct + 8개 DAO.
- **`ApmeCollector.swift`** — `ActiveTask`, `openTaskIfNone(runId:)` / `closeTask(boundarySignal:)`, `UserPromptSubmit` 에서 task 자동 open + `insertTurn` 에 `taskId` 연결, PostToolUse TodoWrite 의 `allTodosCompleted(data:)` 검사, session_end 에서 task 닫힘.
- **`ApmeRunner.swift`** — `enqueueTask(runId:taskId:category:boundarySignal:)` + `runTaskEval`. `buildTaskJudgePrompt` 는 최대 10 turn 을 `[Turn N] User: … / Agent: …` 형태로 프롬프트에 넣고 `task_rollup` rubric 적용. `parseJudgeJson` 에 `summary` 필드 추출 + RESERVED 에 포함(numeric axis 로 잘못 잡히지 않도록).
- **`DaemonServer.swift`** — `handleApmeResult` 에 task-level branch 추가: `★ task X%` timeline entry + `apme_eval` WS 브로드캐스트(기존 turn-level 브로드캐스트 재사용, taskEvals 목록 사용).

### 핵심 설계 결정

- **Claude Code built-in recap 에 의존 안 함**: recap 텍스트는 CLI 에선 transcript 파싱으로, App Store 에선 **judge 가 직접 생성**. 양쪽 빌드가 같은 UX 를 받고, Swift sandbox 가 `~/.claude/projects/` 를 차단한다는 제약을 우회한다.
- **Default judge backend 가 App Store 에선 `foundationModels`**(`ApmeSettings.swift:29`) — task rollup 도 Apple Intelligence on-device 로 실행. MLX 서버 안 띄운 사용자가 아무 추가 설치 없이 task-unit 점수/요약을 받음.
- **App Store invariants 전부 유지**: subprocess 없음, 외부 executable 없음, companion-install prompt 없음, home-relative-path entitlement 없음. `verify-appstore-archive.sh` 가 Release `.app` 에서 pass.
- **SourceKit isolation 이슈 주의**: 전 파일을 `#if os(macOS)` 로 감싼 Swift 파일들은 SourceKit 이 심볼을 못 찾는 false positive 발생. 빌드 타임(`xcodebuild build`) 에서 실제 타겟 compile 로 resolve. 에디터 경고만 보고 코드 수정하지 말 것.

### 검증

- `pnpm test` 967/967 (회귀 없음)
- `xcodebuild test -only-testing:…ApmeTaskBoundaryTests` 12/12 (XCTest: `allTodosCompleted` helper, task lifecycle 5개, parseJudgeJson summary 3개, rubric seed 1개)
- `xcodebuild build -configuration Release` SUCCEEDED
- `verify-appstore-archive.sh` passes on Release `.app`

---

## 2026-04-22 — Dashboard creature polish: Crayfish 재배치 + Octopus 이름표 gap

### 문제

OpenClaw Gateway 가 연결되면 Crayfish 크리처(`crayfishDefaultX=0.78`, `crayfishSittingY=0.64`) 가 TIMELINE 하단 35% 밴드의 **Detail pane 반투명 배경(`Color.black.opacity(0.19)`) 뒤로 비쳐** 텍스트가 ghosting. 추가로 TopologyRail(`maxWidth = min(w*0.32, 300)` → 좌측 경계 대략 x 0.74~) 영역 안쪽에 들어가 UI 패널과도 겹침.

그리고 Claude(Octopus) 크리처의 프로젝트 이름표가 몸체로부터 너무 떨어져 "떠 있는 듯" 보임.

### 해결

**Crayfish 재배치**: x 0.78 → 0.68, y 0.64 → 0.60. 물속 하단에서 몸 바닥이 sand top(y=0.65) 에 닿는 위치. TopologyRail 좌측 경계(x≈0.74) 안 침범, Detail pane 영역(x>0.65, y>0.65) 바깥.

**Octopus 이름표 gap 축소**: `drawTerrariumNameTag(bodyTopY: cy - bodyRadius)` → `cy - bodyRadius * 0.583`. Octopus SVG viewBox 0~24 중 실제 body 는 y=5~20 만 차지하므로 bounding box 상단은 시각적 몸체 상단보다 `0.417 × bodyRadius` 위에 있음. 기존엔 그 공백까지 포함해 gap 이 잡혀서 bodyRadius 60pt 기준 약 25pt 의 잉여 여백 발생. OpenCode(`bodyW * 0.8`), Cloud(`bodyW * 0.6`) 는 이미 각자 factor 를 튜닝한 상태였고 Octopus 만 naive 하게 `bodyRadius` 를 통으로 넘기고 있었음.

### 핵심 설계 결정

- **SVG bounding box ≠ 시각적 body 상단**: Canvas 에 SVG 를 `offsetY = cy - viewBox/2 * scale` 로 그리면 viewBox 최상단이 bounding box 상단. 하지만 path 가 viewBox 전체를 채우지 않는 경우(Octopus 는 y=5 부터 시작) 이름표/UI overlay 는 path 의 실제 y 최소값 기준으로 계산해야 함. 향후 새 creature SVG 추가 시 viewBox 의 body 시작 y 를 바디 상단 ratio 상수로 뽑아 두면 실수 방지.
- **크리처 Y 는 "발이 바닥에 닿는" 기준**: `crayfishSittingY = sandTop - bodyHalfHeight` 공식이 자연스러움. TIMELINE top = 0.65, body half = 0.055 → Y = 0.595~0.60. 다른 creature 도 동일 규칙을 암묵적으로 따르고 있어 (AgentDeck #1 ≈ 0.68, #2 ≈ 0.78 but 둘 다 sand 밴드와 straddle) — 새 creature 추가 시 이 공식 적용할 것.

---

## 2026-04-22 — Dashboard TopologyRail 다듬기: Stream Deck+ 노출 + Ollama chat/embed 분리 + Claude hooks LED + self-probe 버그

### 문제

한 디버깅 세션에서 6건의 대시보드 UX/동작 버그가 연달아 드러남:

1. **Claude row "not connected"** — App Store sandbox 는 `~/.claude/` OAuth token 을 못 읽어 `oauthConnected=false` 고정. TopologyRail + IntegrationStatusEvaluator 가 이 신호만 보고 Claude 를 "awaiting" 으로 찍어 훅이 정상인데도 경고 카드 노출.
2. **Downstream 에 D200H + Pixoo 만 표시** — 4개 ESP32 가 연결돼 `/health` HTTP 에선 보이는데 state_update 에선 누락. `buildModuleHealthSync` 가 SerialModule 의 async snapshot 을 await 못 해 `serial: {available: true}` 만 내보냄.
3. **여러 세션 중 하나만 써도 모든 creature 가 헤엄침** — per-session state 를 handleHookEvent 에서 올바르게 세팅하는데도 10초마다 refreshSessions 가 돌며 `enrichSessionsWithState` 가 **hook-synthesized 세션 (port=9120) 의 /health 를 self-probe** 하고 받아온 글로벌 state 로 각 세션 state 를 덮어씀.
4. **Ollama "installed, no models loaded"** — daemon 이 `/api/ps` 만 조회해 VRAM 상주 안 하는 embedding 모델 (bge-m3 등) 을 "로드 안 됨" 으로 보여줌. 사용자 관점 혼란.
5. **ProviderRow subtitle 말줄임** — 2개 이상 MLX 모델이나 OpenClaw pairing deviceId 힌트가 tail-clipped.
6. **USB serial 라벨** — `"ESP32 · ulanzi_tc001"` 원시 board 문자열 + 펌웨어 버전이 장황함.

그리고 "왜 Stream Deck+ 는 Downstream 에 없나" — 플러그인이 WS 로 daemon 에 접속은 하지만 자기소개 없이 익명 viewer 로 취급돼 렌더 경로가 아예 없었음.

### 해결

**Self-probe 버그 (#3)** `enrichSessionsWithState` 에서 `session.port == self.port` 이면 probe 스킵 — hook-synthesized 세션은 이미 handleHookEvent 에서 올바른 per-session state 가져오므로 덮어쓸 이유 없음. Node CLI bridge 세션 (port 9121+) 은 여전히 정상 probe.

**Serial sync gap (#2)** `cachedSerialStatus` 추가 + 5초 polling task 가 async `serialStatusSnapshot()` 를 미리 당겨 저장. `buildModuleHealthSync` 가 캐시 읽어 full `connections` payload 를 state_update 에 포함.

**Claude hooks LED (#1)** TopologyRail `claudeRow` + IntegrationStatusEvaluator `claudeStatus` 둘 다 `hooksOn || oauthOn` 기준으로 "connected" 판정. App Store 훅 기반 연결이 정상 반영됨.

**Ollama chat/embed (#4)** `probeOllama` 가 `/api/tags` + `/api/ps` 를 병렬 fetch 해 name 매칭으로 sizeVram overlay. `classifyOllamaKind` 가 `details.family` ("bert", "nomic-bert" 등) + name pattern (`bge-`, `-embed`, `gte-`, `e5-`) 으로 chat/embed 태깅. `OllamaModel.kind` 필드 추가. UI 는 두 그룹을 `"Chat: X (loaded), Y\nEmbed: Z"` 두 줄로 분리 렌더.

**Subtitle wrap (#5)** ProviderRow Text modifier 를 `.lineLimit(1).truncationMode(.tail)` → `.lineLimit(nil).fixedSize(horizontal:false, vertical:true)` 로 교체. 한 줄짜리는 그대로, 길면 자연 줄바꿈.

**USB serial 라벨 (#6)** firmware version suffix 제거, `ulanzi_tc001` board → `"Ulanzi TC001"` 브랜드명 매핑 (TopologyRail + MenuBarTopologyList 둘 다).

**Stream Deck+ 노출** 신규 PluginCommand `client_register` 를 shared/protocol 에 추가: 리치 UI 클라이언트가 접속 직후 `{clientType, devices: [{id, name, family, columns, rows}]}` 자기소개. 플러그인 `connMgr.on('connected')` 에서 `streamDeck.devices` 를 family 매핑 (type 7→`streamdeckplus` 등) 해 전송. Daemon 은 `cachedStreamDeck` 에 저장 + 30s eviction task 에 piggyback 한 120s TTL 로 stale 정리. `ModuleHealthState.streamDeck` + `StreamDeckHealth`/`StreamDeckDeviceInfo` 모델 + `BridgeEventParser` 확장 + TopologyRail `streamDeckSection` (Downstream 첫 번째). family 기반 display label + `{cols}×{rows} keys` detail.

### 핵심 설계 결정

- **Self-probe 회피 원칙**: hook-synthesized session 은 daemon 자기 port 를 공유하므로 enrichment probe 가 self-loop. 이런 경우 probe 스킵하고 in-memory per-session state 를 authoritative 로 사용 — "작성자가 state 를 알고 있으면 re-fetch 하지 않는다".
- **`client_register` 프로토콜 원시타입**: 리치 UI 클라이언트의 self-announce 경로. 지금은 Stream Deck+ 만 쓰지만 향후 Android companion, 외부 iOS pairing 도 같은 경로로 announce → daemon 의 `handleClientRegister` switch 에 분기 추가하면 끝.
- **Ollama `kind` 분류 휴리스틱**: `details.family` 가 1차 신호 (bert/nomic-bert/distilbert/roberta 가 embed), name pattern 이 fallback. Ollama 의 구/신 버전 응답 스키마 모두 수용.
- **Sandbox-async gap 처리 패턴**: sync broadcast path 에서 async 모듈 snapshot 이 필요하면 cached mirror + 5s polling 이 가장 단순. ESP32Serial (actor) 이 이 케이스.
- **UI 자기-gating**: 진단 copy ("Stream Deck not detected", "Install plugin" 등) 금지. 외부 신호가 없으면 섹션 자체를 숨김 — App Store Progressive Enhancement 원칙 일관.

---

## 2026-04-22 — SD/SD+ active-agent 시각 강화 + USAGE dial 라벨 정직화 + SD 플러그인 설치 감지

### 문제

세 건의 UX 지적이 한 세션에서 나옴:

1. **"진행 중 에이전트 표시가 너무 눈에 안 띈다"** — SD/SD+/D200H 모두 세션 버튼 우상단에 16×16 회전 스피너 한 개만 있어 세 세션이 동시에 뜨면 어느 것이 실제로 돌고 있는지 한눈에 판별이 안 됨. AWAITING_PERMISSION 은 이미 2.5px 펄스 테두리가 있는데 PROCESSING 은 테두리 자체가 없었음.
2. **SD+ USAGE dial 이 "Waiting..." 으로 계속 보인다** — API-key 계정·App Store 샌드박스처럼 **영원히 usage 데이터가 안 오는** 케이스도 "Waiting" 이어서 "곧 올 것처럼" 보이는 오해 유발.
3. **메뉴바에 "Install SD plugin" 링크가 이미 설치된 상태에서도 뜬다** — 링크도 번들된 `.streamDeckPlugin` 이 없으면 `github.com/.../releases/latest` 로 떨어져 실사용자에게 불친절.

### 해결

**A. 세션 타일 테두리 — flowing light (3 파일)**

- `shared/src/svg-renderers/session-slot-renderer.ts`: PROCESSING 에 dashed flowing border 신설. `stroke-dasharray="22 14"`, `stroke-dashoffset = (animFrame * 4) % perimeter` 드리프트, Gaussian blur 필터, 4.5px 외부 + 1.5px 내부 이중 링. AWAITING 펄스도 2.5→4.5px 로 굵기 강화 + sin opacity 범위 0.55~1.0 + 내부 링 추가. 우상단 회전 스타 스피너 제거, ACT 뱃지는 IDLE 전용으로 축소.
- `plugin/src/actions/session-slot-button.ts`: `needsAnimation()` 이 `awaiting*` 만 true 반환해서 PROCESSING 일 땐 150ms 타이머가 아예 안 돌고 있었음 — `animFrame` 이 0 에 고정돼 dashoffset 이 정지. `session.state === 'processing'` 체크 추가 후 애니메이션 활성화.
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`: 스테이블-스톡 HID 모드에서 세션 타일이 `.solid(color)` 로 떨어지던 fallback 제거 (lineWidth 2, alpha 0.6 → 너무 얇음). 항상 `.awaitingPulse` / `.processingDash` 를 emit, 스테이블 모드에선 애니 루프가 `setNeedsAnimation` 에서 강제 off 되므로 정적 스냅샷이 됨. 펄스 frame 을 5 로 pin 해서 sin peak (≈0.997) 에 고정 (0 frame 이면 opacity ≈0.3 어둡게 걸림). `.processingDash` draw 자체도 lineWidth 3→5, shadow blur 8 추가, crisp 내부 pass 추가.

**B. USAGE dial 라벨 정직화 (2 파일)**

- `plugin/src/renderers/usage-dial-renderer.ts`: `renderUsageDisconnected(connected, reason)` 시그니처에 `reason: 'offline' | 'waiting' | 'unavailable'` 추가. "No usage data" 라벨 신설.
- `plugin/src/actions/iterm-dial.ts`: 호출부를 세 분기로 재작성 — (a) 세션 disconnected → 'offline', (b) 접속됐으나 `hasReceivedData=false` → 'waiting', (c) `usageStale === true` 또는 `fiveHourPercent == null` → 'unavailable'.

**C. SD 플러그인 설치 감지 (1 파일)**

- `apple/AgentDeck/UI/MenuBar/ControlTowerPanel.swift`: `StreamDeckDetection` 에 `pluginInstalled: Bool` 필드 추가. `detectPluginInstalled()` 가 `getpwuid(getuid())` 로 real home 을 얻고 `~/Library/Application Support/com.elgato.StreamDeck/Plugins/bound.serendipity.agentdeck.sdPlugin/manifest.json` 존재 확인 — 2026-04-16 `swift-daemon-server.md` 메모리 노트의 기법 재사용. `streamDeckPromptCompact` 가 이제 `{SD+ connected, Elgato app present, plugin not installed}` 3조건 모두 충족해야만 "Install SD plugin" 렌더, 아니면 버튼 자체가 안 뜸.

### 핵심 설계 결정

- **D200H 는 라이브 애니 없이 "정적이지만 굵은 glow"**: 유저가 SD/SD+ 만 흐르는 애니메이션, D200H 는 정적 썰매를 택함. D200H 는 프레임 루프 추가 비용(14 PNG resvg/Core Graphics 재렌더 × ~8fps) 이 지나치게 크고, 스테이블-스톡 HID 모드는 애니 루프 자체가 금지됨. 같은 SVG 가 SD/SD+ 에선 150ms 틱에 dashoffset 이 움직이고, D200H 에선 `animFrame` 이 한 값에 고정돼 자연스럽게 정적 dashed glow ring 으로 렌더된다는 "동일 코드 경로, 다른 틱 상황" 설계로 해결.
- **샌드박스 경로 체크는 silent-false 허용**: `detectPluginInstalled()` 가 App Sandbox 하에서 Elgato Plugins 폴더 읽기가 막히면 조용히 `false` 반환. App Store 빌드에선 최악의 경우 "Install SD plugin" 을 한 번 더 보지만, 이 nudge 는 원래부터 hint 일 뿐 gate 가 아니므로 허용 가능한 fallback. home-relative-path entitlement 재도입보다 UX 퇴행이 낫다 (App Store 2.5.2 invariant 보존).
- **USAGE 라벨 3분기는 의도적으로 `fiveHourPercent == null` 을 "unavailable" 에 포함**: 서버사이드에서 수치를 낼 수 없는 경우(OAuth 없는 API-key 계정 등) 와 "곧 올 것" 의 의미 경계를 **플러그인에서** 넷째 경우로 가정하지 않고, daemon 이 이미 unavailable 로 판정한 것(`usageStale=true`)이든 애초에 값 자체가 없는 것이든 **동일 라벨**로 묶음. 사용자 입장에선 "안 오는 것" 은 구분 의미 없음.

---

## 2026-04-21 — Stream Deck plugin: install package structure + daemon port discovery + profile rename

### 문제
사용자 보고: Pixoo/D200H/TC001/macOS Terrarium 은 두 claude 세션 크리처를 정상 렌더하는데 **Stream Deck / Stream Deck+ 에서만 상태가 안 보인다**. App Store macOS 앱만 실행 중(CLI daemon 없음)이고 raw `claude` 인스턴스 2개가 훅만으로 동작하는 상태. 중간에 `pnpm package` 로 만든 `.streamDeckPlugin` 을 수동 재설치하려고 하자 Elgato 가 "플러그인 항목을 설치할 수 없다" 오류.

### 실제 근본 원인 (3겹 — 중요도 순)

1. **Plugin daemon.json 경로 단일화 누락.** `plugin/src/connection-manager.ts:findDaemonPort()` 가 `~/.agentdeck/daemon.json` 한 곳만 조회. Swift in-process daemon(App Store sandbox) 은 `~/Library/Group Containers/group.bound.serendipity.agentdeck.dashboard/daemon.json` 에 쓰므로 plugin 은 영구히 "daemon port=not found" 상태가 되고 모든 WS send 가 `dropped — not connected` 로 빠짐. 훅 스크립트는 이미 `cross_dir_daemon_discovery.md` 메모리 노트대로 **두 경로 모두** 시도하는데 plugin 만 구경로였음. **SD/SD+ 가 아무것도 렌더하지 않던 진짜 이유**.

2. **Package zip 구조.** `scripts/package-plugin.sh` 가 `.sdPlugin` 폴더 *내용물* 을 zip 루트에 넣고 있었음 (line 22 주석 "zip the .sdPlugin directory contents (NOT the .sdPlugin folder itself)" — 정반대가 맞다). Elgato 는 `<plugin>.sdPlugin/manifest.json` 구조를 기대하므로 `ContentError: No manifest.json found in package` 로 설치 거부. 추가로 `logs/` 디렉터리가 zip 에 포함되어 52MB 이상의 개발 로그가 배포 아티팩트에 묶여 9.3MB 패키지가 됨.

3. **번들 프로파일 하드웨어 바인딩.** `agentdeck-v4.sdProfile/manifest.json` 의 `Device.UUID: "@(1)[4057/132/A5Z5A41911U6X7]"` 가 특정 개발자 기기의 Stream Deck+ serial 에 고정돼 있어, 다른 유저의 SD+ 에서는 Elgato 가 device 매치에 실패해 AutoInstall 을 거부. 이 자체는 blocker 는 아니나(유저 기존 프로파일에 이미 AgentDeck 액션들이 세팅돼 있음) 공개 배포용 packaging 결함.

### 해결

**Plugin 쪽 (3 파일, 1 커밋)**:
- `plugin/src/connection-manager.ts` — `findDaemonPort()` 에 Group Container 경로 fallback 추가. 첫 live PID 매치 승리. 14 connection-manager 테스트 그대로 통과.
- `scripts/package-plugin.sh` — `.sdPlugin` 폴더를 zip 루트에 두고 `logs/` / `node_modules/` / `*.log` 제외. 9.3MB → 448K, Elgato 설치 성공.
- `plugin/.../manifest.json` + `plugin/src/plugin.ts` — 번들 프로파일 이름 `agentdeck-v4` → `agentdeck-sdplus` (Elgato 가 persist 한 "dropped embedded profile 'agentdeck-v4:7'" 캐시 우회 의도). 프로파일 폴더 이름, 내부 page UUID(598AC8E4 → D3714493), `switchToProfile(…)` 호출 인자 모두 동기화. `Device.UUID` 제거, `Plugin.Version` 0.3.0.0 → 0.4.0.0 일괄.

### 잔존 한계 (fixing X 로 남김)

Elgato 는 여전히 `import aborted [bound.serendipity.agentdeck]: no matching or required profiles found` 를 출력. 앱 완전 재시작, 심볼링크 대신 실제 복사본 설치, Device.UUID 제거, 이름 변경, 버전 정합까지 시도해도 같은 에러. Elgato 측 embedded-profile 인덱스가 바이너리 plist/메모리에만 존재하는 것으로 추정됨(문자열 grep 에 안 잡힘). **다만 실제 문제가 아님** — 사용자 `9F989E75-A8FF-4953-939E-FCA852F7FB6A.sdProfile` (SD+ Default Profile) 내부에 AgentDeck 액션들(Utility/Option/Usage/Voice/Session Slot) 이 이미 세팅되어 있어 AutoInstall 없이도 버튼 렌더링 작동.

### 핵심 설계 결정 (재발 방지 앵커)

- **"daemon.json 은 두 경로"는 프로토콜 레벨 invariant.** 훅 스크립트, plugin, 향후 도구 전부 Node `~/.agentdeck/` 와 Swift Group Container 두 곳을 조회해야 한다. 한 군데만 쓰는 소비자는 App Store 모드에서 조용히 죽는다. 메모리 노트 `cross_dir_daemon_discovery.md` 의 "3 경로 byte-identical" 가 hook snippet 에만 해당하는 착각을 유발하지 않도록 plugin connection-manager 에 이 주석을 박음.
- **Plugin packaging 구조는 `streamdeck pack` CLI 기준 — `.sdPlugin` 폴더가 zip 단일 top-level.** 과거 주석은 오해를 유도했고 `ContentError` 재발의 씨앗이었다. 스크립트 주석에 "Elgato rejects the package with 'No manifest.json found in package' unless the .sdPlugin folder is the zip's single top-level entry" 를 명문화.
- **번들 프로파일의 `Device.UUID` 는 절대 포함하지 않는다**. 특정 하드웨어에 고정되는 순간 다른 유저의 AutoInstall 이 실패한다. `Device.Model` 까지만.
- **Elgato 의 "dropped embedded profile" 상태는 클리어 불가 가정 하에 움직인다.** 프로파일 이름을 바꾸거나 내부 UUID 를 재생성하는 우회가 필요할 수 있음을 인지. 실제 사용자에게는 AutoInstall 실패해도 드래그 배치로 우회 가능하므로 blocker 로 다루지 않는다.

### 관련 파일
- `plugin/src/connection-manager.ts` — `findDaemonPort()` dual-path
- `scripts/package-plugin.sh` — zip 구조/제외 규칙
- `plugin/bound.serendipity.agentdeck.sdPlugin/manifest.json` — Profiles[0].Name
- `plugin/src/plugin.ts` — `switchToProfile(..., 'agentdeck-sdplus')`
- `plugin/bound.serendipity.agentdeck.sdPlugin/agentdeck-sdplus.sdProfile/` — rename + generic Device
- 커밋: `c64161e0 fix(plugin): Stream Deck package structure + cross-dir daemon.json discovery`

---

## 2026-04-20 — Xcode issue-navigator: priority inversion, sandboxing, iOS build error

### 문제
세 가지 Xcode Issue Navigator 경고/에러가 동시에 발생.

1. **Hang Risk**: `D200hHidModule.swift:85` — "User-initiated quality-of-service class waiting on a lower QoS thread running at Utility." `HIDRunLoopThread.init()`이 `.utility` QoS thread를 만들고 MainActor-inherited UserInitiated context에서 `ready.wait()`로 블록.
2. **Update to recommended settings**: `ENABLE_USER_SCRIPT_SANDBOXING`이 project.pbxproj에 없어 Xcode 16이 경고.
3. **iOS build error**: `DevicePreviewScreen.swift:37` — `DaemonService`가 `#if os(macOS)` 전체를 감싸고 있는데 iOS 컴파일 시 `@EnvironmentObject` 선언이 플랫폼 가드 없이 노출.

### 해결
1. `HIDRunLoopThread.thread.qualityOfService = .utility` → `.userInitiated`. HID 입력은 사용자 상호작용 직접 응답이므로 `.userInitiated`가 맞고, 이로써 wait 호출자와 QoS 레벨이 일치해 priority inversion 해소.
2. `project.yml`에 `ENABLE_USER_SCRIPT_SANDBOXING: NO` 추가 후 `xcodegen generate`. `copy-adb.sh` build phase가 sandbox 외부 경로에 접근하므로 NO가 정확하고, 명시적 선언으로 Xcode 경고 제거. 이 설정은 **빌드 타임 전용**이며 앱 런타임 sandbox나 App Store 심사에 영향 없음.
3. `DevicePreviewScreen.swift`의 `@EnvironmentObject var daemonService: DaemonService`, `visibleDevices` computed property, `.onChange(of: daemonService.isUsingExternalDaemon)` 세 곳 모두 `#if os(macOS)` 가드 추가. iOS에서는 `visibleDevices`가 `requiresDesktopBridge`를 필터링한 목록만 반환.

### 핵심 설계 결정
- `ENABLE_USER_SCRIPT_SANDBOXING` = build-time setting, App Store entitlements와 무관. 향후 이 설정에 대한 "앱스토어 심사 통과?" 질문이 나오면: 영향 없음 확인.
- `HIDRunLoopThread` QoS: D200H HID 이벤트 처리는 사용자 입력에 직접 응답하므로 `.userInitiated`가 의미상으로도 정확.

---

## 2026-04-19 — D200H clock overlay — reproduced and permanently suppressed

### 문제
사용자 보고: D200H 실기기에서 "일부 흐릿하게 크리처는 보이는데 버튼이 제대로 안 나오고 눌러도 반응이 없고 오른쪽 아래에 기본 시계가 보이는" 증상. 덧붙여 "전에도 몇 번 이래서 고쳤던 적이 있다 — 왜 이런 상황이 종종 발생하는가"라는 메타 질문. `stock-safe-v19` 까지 renderer revision 이 올라가 있었던 것 자체가 반복 회귀의 화석 증거.

### 실제 근본 원인 (3겹)
1. **LaunchAgent 경합**. `dev.agentdeck.daemon` LaunchAgent 가 자동으로 Node CLI daemon 을 띄워 9120 을 선점 → Xcode Run 한 Swift 앱이 `external-client` 모드로 fallback → Swift in-process daemon 이 기동조차 하지 않아 수정된 코드가 실행되지 않음. Node 쪽 `bridge/src/modules/d200h-module.ts` (376줄) 는 Swift 쪽 (3053줄) 의 레거시 축소판이라 session list / option mode / usage monitor slot 13 / SVG 렌더러가 모두 없어, 사용자 눈에는 **단순 키 배열 + 기본 시계** 로 "퇴행" 돼 보임.
2. **Merged slot `3_2` manifest 의 Action 필드 trap**. `renderFullZip` 의 session-list 브랜치는 `clearAction: true` → `Action=""` 로 올바르게 설정해 stock smallwindow 레이어를 suppress 하지만, option-select 브랜치는 `actionPath: "agentdeck://back"` 으로 **펌웨어가 모르는 URI** 를 넣어 `Action=com.ulanzi.ulanzideck.system.open` 로 export → 펌웨어가 기본 smallwindow clock widget 을 fallback 으로 render. 같은 파일 내에 "올바른 방식" 과 "틀린 방식" 이 공존했다.
3. **`sendKeepAlive()` 오도된 복구 시도**. 당일 1차 수정에서 "기본 시계를 우리 텍스트로 덮자" 는 의도로 `CMD_SET_SMALL_WINDOW` 를 15초마다 push 하도록 재활성화했으나, D200H 펌웨어는 SMALL_WINDOW 레이어를 **manifest icon 과 같은 좌표 위에 합성** 하는 구조였다. 결과적으로 "usage 텍스트 + clock" 두 readout 이 동시에 보이는 새로운 "겹침" 증상을 추가 생성.

### 해결
**운영 (one-shot)**:
- `agentdeck daemon uninstall && pkill -f "agentdeck daemon start" ; launchctl remove dev.agentdeck.daemon` — LaunchAgent 영구 제거. 그 뒤 Swift in-process daemon 이 9120 을 잡는다.

**코드 (stock-safe-v19 → stock-safe-v20, `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` 한 파일)**:
- **`sendKeepAlive()` 함수 자체를 삭제**. 함수가 남아있으면 다음 누군가가 "호출 안 되는 dead code 같은데" 라며 다시 호출처를 추가할 리스크 — 실제로 오늘 사이클에서 발생.
- **Option-select 모드의 `3_2` entry 도 `clearAction: true` 로 통일**. 양쪽 호출부 두 군데 (`renderFullZip` `else` branch + `renderPartialZip` slot 13 branch).
- `buildSmallWindowPacket` 에 "TRAP: do NOT call from steady-state rendering" 주석.
- `manifestEntry` 에 "slot 3_2 는 반드시 `clearAction: true`" invariant 주석.
- 별개로 같은 날 추가한 가드들 (회귀 방지 효과 동일): `buildValidatedZip` 실패 시 `Data()` drop + 호출자 `zip.isEmpty` 체크, keyboard interface 2초 미연결 경고, input report buffer leak fix, `logOpenFailure` 레벨 debug→info.

### 검증
- `xcodebuild -project AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' build` — `** BUILD SUCCEEDED **`.
- 사용자 확인 (실기기 in-the-loop): "완전 해결되었다".
- Runtime `/health` 에 `rendererRev: 'stock-safe-v20'`, `sandboxEnabled: true`, `usbEntitlementPresent: true`, `managerOpened: true`, `hasConsumerDevice: true`, `hasKeyboardDevice: true` 기준.

### 핵심 설계 결정 (재발 방지 앵커)
- **Stock-clock 차단은 manifest-only 경로로 단일화**. SMALL_WINDOW 레이어로 "우리 텍스트를 올려 덮자" 는 유혹은 이번 사이클이 세 번째 시도였고 세 번 모두 악화. `Action=""` (`clearAction: true`) 이 **유일한** 확인된 escape hatch. 2026-04-12 1차 발견 → 2026-04-12 2차 혼동 ("smallwindow.window 유지" 로 revert) → 2026-04-19 재확립.
- **"dead function 이면 삭제한다"를 D200H 파일에 한정해 원칙화**. "일단 남겨두자" 는 관용이 `stock-safe-v19 → v20` 회귀 사이클의 원인 중 하나.
- **`3_2` entry 의 Action 필드 invariant 를 코드/로그/메모리 세 곳에 박음** — `manifestEntry` 주석, 이 entry, `memory/d200h-hid-protocol.md`. 다음 코더가 어떤 경로로 진입하든 "왜 Action 이 빈 문자열이어야 하는가" 에 즉시 도달.
- **펌웨어 경합의 해결은 OS 레벨 (LaunchAgent 제거) 에서만 가능**. 코드 수정으로는 풀 수 없다 — Swift daemon 이 실행조차 안 되는 상태라 어떤 `D200hHidModule` 변경도 무의미. 운영 플레이북에 명시.

### 관련 파일
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` (v19 → v20; `sendKeepAlive()` 삭제, option-mode `clearAction: true`, invariant 주석).
- `memory/d200h-hid-protocol.md` (SMALL_WINDOW 사용 금지 + manifest-only suppression 명문화).
- `~/.claude/plans/d200h-pure-goose.md` (진단 플랜 — 이 entry 로 대체됨).

---

## 2026-04-19 — non-AppStore macOS 분기 일괄 제거 + 외부 daemon 게이트 (progressive enhancement)

### 문제
macOS 타겟은 `apple/project.yml:61` base setting 으로 `AGENTDECK_APP_STORE` flag 를 항상 켜고 빌드한다. 즉 `#if !AGENTDECK_APP_STORE` 분기는 macOS 에서 한 번도 컴파일되지 않는 dead code. 그런데 이 분기 안에 `Process()` / `.command` 작성 / AppleScript / `openclaw doctor` / `networksetup` / `security` / `sqlite3` / bundled Node spawn / "Switch D200H to Bundled Helper" Settings UI 같은 Apple Review 2.5.2 위반 가능 코드가 1.4k LOC 살아있었다. 4-19 안티패턴 로그가 이미 한 번 회귀-복구 사이클을 기록한 상태였고, 같은 회귀가 다시 들어오면 컴파일 시점에 잡히지 않는다는 게 위험. 동시에 standalone App Store 앱은 sandbox 한계로 못 하는 기능(Claude OAuth quota, ADB-bridged Android/TC001)에 대해 "sandbox unavailable" 메시지를 노출하고 있어서, "결함 있는 앱"으로 보였다.

### 해결
**Phase 1 — 분기 자체 삭제 (`#if !AGENTDECK_APP_STORE` macOS).**
- `SessionLauncher` 를 NSAlert facade 로 단순화. `TerminalApp`/`LaunchPlan`/`openInTerminal`/`openInITerm`/`showAgentInstallPrompt` 전부 제거 (~150 LOC).
- `LaunchSessionDialog` 의 terminal 피커 삭제 (alert-only 로 가니 의미 소멸).
- `DaemonService.startBundledD200HHelper`/`stopOwnedExternalDaemonIfNeeded`/`resolveBundledD200HHelper`/`resolveRepoNodeDaemonLaunch`/`helperEnvironment` + `externalDaemonProcess`/`ownsExternalDaemon`/`d200hHelperPromotionAttempted` state + auto-promotion health-monitor branch 제거.
- `AdbModule` 을 status-only stub 으로 축소 (외부 daemon 이 broadcast 로 device 데이터 relay).
- `WifiConfig` 의 `networksetup`/`security` shellSync 제거 (사용자 수동 입력만).
- `BridgeLogStream` 을 parser-only 로 축소 (`OpenClawAdapter` 만 `parseLogLine` 사용).
- `GatewayProbe.checkHealth` 의 `openclaw doctor` 제거 (Gateway RPC 가 health 정보 push).
- `ESP32Serial.shellSync` / `UsageAPIClient.getOAuthCredentials` `/usr/bin/security` / `UsageAPIClient.sqliteValueViaShell` `/usr/bin/sqlite3` / `AnthropicAdminApiClient.currentKey` env-var fallback 제거.
- `SettingsScreen` 의 "D200H Helper" 섹션 + `autoUseBundledD200HHelper` `AppPreferences` 제거.
- `OnboardingSheet` 의 install-command 카드 + "Open Guide" 버튼 + 두-버튼 yes/no 인터랙션 제거 (App Store branch single-source).
- `IntegrationsView` 의 4개 copy 분기 collapse → App Store copy 단일화. Codex/Claude 안내 문구도 sandbox 한계 직접 언급하지 않게 재작성.
- `copy-adb.sh` 본체 비우고 `agentdeck-d200h-helper.sh` 삭제. `SessionLauncherTests` 의 `bundledBridge`-의존 케이스 제거 (DaemonService 단독 케이스 3개만 남김).

**Phase 2 — `DaemonService.isUsingExternalDaemon` 게이트로 progressive enhancement.**
- `ControlTowerPanel.rateLimitsSection` 에 가시성 조건 추가: 실제 gauge 데이터가 있거나 외부 daemon 이 붙어있을 때만 RATE LIMITS 헤더+섹션 노출. 미감지 시 `hookConsentHint` 만 분리 표시. `rateLimitsEmptyMessage` 도 "sandbox 안내" 톤 → "외부 daemon 동기화 중" 톤으로 재작성.
- `DevicePreviewCatalog.PreviewDevice.requiresDesktopBridge` 추가 (`androidTablet`/`einkMono`/`einkColor`/`ulanziMatrix`). `DevicePreviewScreen` 에 `@EnvironmentObject DaemonService` 주입 + `visibleDevices` 필터. 사이드바가 빈 카테고리는 자동으로 숨고, 외부 daemon 토글 시 `onChange`/`onAppear` 가 현재 선택을 안전한 첫 항목으로 옮김.
- `TopologyRail.emptyDownstreamPlaceholder` 의 "Android / Ulanzi TC001 are unavailable in the App Store build" 카피 삭제. 기존 `androidSection`/`pixelDisplaySection` 은 이미 `classifiedDevices`/`tc001Devices` 비어 있으면 렌더 안 하므로 외부 daemon 부재 시 자연스럽게 hide.

**검증**: `xcodebuild Release` 통과, `verify-appstore-archive.sh` 통과, macOS 단위 테스트 60개 전부 통과.

**문서/메모리**: `docs/appstore-feature-matrix.md` (Claude 구독 사용량 행 + 요약 + Anti-patterns 섹션 갱신), `apple/APP_REVIEW_NOTES.md` ("source tree contains no Process()" 로 강화), `CLAUDE.md` "App Store build invariants" (compile flag 의미 변경 + progressive enhancement bullet 추가), `docs/appstore-metadata-draft.md` ("install Claude Code first" 리드인 → "works immediately after install" 로 교체), `memory/appstore-invariants.md` (dead code 일괄 삭제 + progressive enhancement 패턴 기록).

### 핵심 설계 결정
- **`AGENTDECK_APP_STORE` flag 자체는 유지**. CI verifier 의 의미적 앵커이자 향후 회귀 가드. 다만 의미가 "분기 컴파일 조건" 에서 "검증 토큰" 으로 바뀌었다 — 새 subprocess 코드는 어떤 guard 안에서도 들어오면 안 됨 (CLAUDE.md 에 명시).
- **`OpenClawAdapter` identity 는 App Store Keychain v3 단일 경로로 정리**. 2026-04-19 후속 정리에서 Swift macOS 소스 트리의 file-based `~/.openclaw/identity/` fallback, `Process()` 기반 `openclaw models list`, `/usr/bin/env which openclaw` 경로를 제거했다. v2 file-based identity 는 Node CLI bridge 책임이고, macOS GUI 제품 경로가 아니다. `AnthropicAdminApiClient` Keychain 저장소는 유지.
- **`!isUsingExternalDaemon` UI 정책: 메시지 대신 hide**. "Subscription quota unavailable inside the sandbox" 같은 친절한 안내 카피는 사용자에게 "결함" 인상을 준다. 외부 daemon 미감지 시 해당 섹션 자체를 안 보여주는 쪽이 standalone 앱 완결성에 유리하다 — 사용자 답변 ("CLI daemon 없이는 보여줄 수 없으니 해당 영역 자체를 숨기면 좋겠다", "완결성있게 기능 제공이 가능한 것 처럼 보여주고 싶다") 직접 반영.
- **macOS 전용 build configuration variant 는 안 만든다**. 즉 `SWIFT_ACTIVE_COMPILATION_CONDITIONS=""` 로 강제로 끄고 빌드해서 비-AppStore macOS GUI 를 부활시키지 않는다. 그 제품 path 는 더 이상 maintained 가 아니고, full-spec 은 별도 Node.js bridge CLI 의 책임.
- **iOS 분기는 손대지 않음**. `AGENTDECK_APP_STORE` 는 macOS 전용 flag (project.yml 에 iOS 타겟엔 설정 없음); iOS 는 항상 App Store 전용이라 분기가 필요 없음.

---

## 2026-04-19 — iTerm2 postit 오버레이 축소 (project + model 만 표시)

### 문제
`bridge/src/terminal-status.ts` (559줄) 의 iTerm2 postit 배지는 도구 호출 이력 → MLX/Ollama 로 라운드·세션 요약 → 최대 5개 마일스톤 누적 + 휴리스틱 폴백까지 덩치가 컸다. Claude Code 본체에 recap 기능이 내장된 뒤로는 이 두 번째 히스토리 뷰가 상시 중복이 됐고, 배경에서 돌아가는 MLX/Ollama 요약 호출이 가치 없이 로컬 LLM 자원을 점유했다.

### 해결
postit 역할을 "지금 이 탭이 어느 프로젝트의 어느 모델에 붙어있는지" 만 알려주는 식별자로 축소.
- 배지 2줄: `📂 {project}` / `{model}` — 모델이 `null` 이면 한 줄
- 탭 제목: `{project} · {model}` — 상태 아이콘·tool detail 제거
- User Variables: `agentdeck_project` + `agentdeck_model` (기존 `_state`/`_tool` 삭제)
- `BADGE_MAX_HEIGHT_FRACTION` 0.35 → 0.1

`bridge/src/terminal-status.ts` 559 → 167줄. `bridge/src/timeline-summarizer.ts` 에서 유일 caller 를 잃은 `summarizeSessionContext` / `summarizeRound` / 전용 프롬프트 + 연쇄적으로 죽어 있던 `callLLMWithFallback` / `callMLXGeneric` / `callOllamaGeneric` / `callLLMMultiLine` / `callLLMRaw` 를 함께 제거 (232줄). OpenClaw 경로의 `summarizeResponse` 는 유지.

### 핵심 설계 결정
- Dynamic Profile / dark-light 감지 / tmux 래핑 / `--no-postit` 플래그 / 200ms debounce / `stateMachine.state_changed` 훅은 그대로. 축소는 "표시 내용" 만 건드리고 "표시 경로" 는 건드리지 않음 — 이후 롤백이 필요할 때 render/buildBadge 만 부풀리면 됨.
- 탭 제목에서 상태 아이콘(`●`/`◇`) 도 제거. recap 이 탭 바깥에서 본인 역할을 하므로 탭 제목은 순수 식별자로 쓰기로 함.
- MLX/Ollama 호출 경로 자체가 사라져서 local LLM 서버 부재 시 실패 backoff 코드도 안 걸림 (`bug_local_llm_probe_no_backoff` 메모리의 한 축을 정리).

---

## 2026-04-19 — Shutdown deadlock 제거 (MainActor + DispatchSemaphore)

### 문제
`AppDelegate.applicationWillTerminate` 이 `DaemonService.stop()` 완료를 기다릴 때 `DispatchSemaphore.wait(timeout: 10s)` 로 main thread 를 블록하고 있었다. `stop()` 은 `@MainActor` isolated async 메서드라 실행하려면 main thread 가 필요한데, main 이 세마포어에서 자고 있으니 Task 가 깨어날 수 없다 → 10초 전부 소진 후 "Shutdown exceeded 10s — forcing exit" 로그로 강제 종료. Xcode Debug 실행을 반복하면 데몬 정리가 끝나기도 전에 프로세스가 죽어 `SX` 상태 좀비가 누적되는 원인이기도 했다 (`memory/xctest-zombie-blocker.md` 의 "Stop 버튼 → zombie" 경로).

### 해결
`apple/AgentDeck/App/AgentDeckApp.swift:186` — semaphore 대신 3초 deadline 동안 `RunLoop.main.run(mode: .default, before: +0.05s)` 를 반복해 pump 한다. `Task { @MainActor }` 가 windows 사이마다 흘러 들어가 `stop()` 이 끝나면 `done = true` 로 루프 탈출. 실측 `stop()` 은 client-mode 빌드에서 100ms 이하이므로 3초 failsafe 면 충분하다.

### 핵심 설계 결정
- MainActor-isolated async 를 기다릴 때 semaphore 는 무조건 deadlock. 교과서적인 패턴.
- `run(before:)` 짧은 창으로 pump 하는 이유: 한 번에 긴 `distantFuture` 를 주면 NSApplication 종료 event 와 경합. 50ms 슬라이스가 Task 실행 기회를 안정적으로 준다.

---

## 2026-04-19 — Attention popup 동적 옵션 렌더 + e-ink 게이트

### 문제
Dashboard 의 "Attention" popup 이 Claude Code CLI 의 실제 permission prompt 를 왜곡해서 보여주고 있었다. tool-use 승인은 Yes/No/Always 3개지만, plan approval 은 5+ 옵션, `/openclaw` OAuth 는 Scope → Token → Submit 같이 단계별 sequential prompt, AskUserQuestion 은 임의 numbered list — 모두 각각 라벨/개수가 다른데 Apple `AttentionTheaterHUD`/`AttentionTheaterView` 와 Android `AttentionTheaterHUD` 가 Yes/No/Always 를 3개 버튼으로 하드코딩하고 `state.options` 배열을 통째로 무시하고 있었다. 결과적으로 5-옵션 plan 승인이나 OpenClaw Scope 단계도 "yes/no/always" 3개 버튼으로 나왔다. 부수적으로 Android e-ink 기기(CremaS/Onyx/Kobo/Pantone)도 이 interactive popup 을 띄우고 있었는데 e-ink 리프레시 특성상 버튼 UI 는 부적합했다.

### 해결
- **파서/프로토콜은 이미 완비돼 있었다.** `bridge/src/output-parser.ts:437-528` 이 `permission_prompt`/`option_prompt`/`diff_prompt` 를 emit 할 때 이미 `options: PromptOption[]` + `navigable` + `cursorIndex` 를 실어 보내고, `bridge/src/bridge-core.ts:217-242` 가 상태별로 `promptType`(yes_no / yes_no_always / multi_select / diff_review) 을 계산해 `state_update` 에 박아준다. Apple `DashboardState.options/promptType/navigable/cursorIndex` 와 Android `DashboardState.options/promptType/navigable/cursorIndex` 도 이미 populate 되어 있었다. **UI 만 그 데이터를 무시하고 있었다.**
- **Apple 동적 렌더**: `AttentionTheaterHUD.swift` + `AttentionTheaterView.swift` 를 `ForEach(state.options)` 루프로 재작성. ≤3 짧은 라벨(≤14자) 은 수평 3버튼(classic green/red/cyan 팔레트 유지), 그 외(>3 옵션 또는 긴 라벨 또는 `promptType == .multiSelect`) 는 수직 스크롤 리스트(neutral 팔레트 + recommended 녹색 + deny-like 빨강). `cursorIndex` 외곽선, `selected` ✓, `recommended` 굵은체. 빈 배열은 yes/no/always trio 로 fallback.
- **Apple 동적 키보드 단축키**: `MonitorScreen.KeyboardShortcutsModifier` 에서 ⌘Y/⌘N 하드바인딩 제거. 대신 현재 `options.shortcut` 을 매칭하고(⌘y/⌘n/⌘a/⌘v/⌘d 등 파서가 라벨에서 추론한 값), 없으면 인덱스 기반 ⌘1..⌘9 fallback. 옵션 없으면 defensive 하게 ⌘Y/⌘N 유지. ⌘. (interrupt) / ⌘Return ("go on") 은 awaiting 여부와 무관하게 유지.
- **Android e-ink 게이트 (핵심)**: `android/.../MonitorScreen.kt:722` 의 popup 호출 조건에 `!EinkDetector.isEinkDevice()` 추가. e-ink 기기는 popup 자체를 숨기고, 기존 "크리처 옆 물음표" static indicator 만 유지. 태블릿/폰/macOS 에서만 interactive popup 이 뜬다. `EinkDetector` 는 이미 CremaS/Onyx/Kobo/Pantone/Boyue/PocketBook/reMarkable/Supernote/Bigme/Dasung/Hisense/MOAAN 까지 판별한다 (`android/.../util/EinkDetector.kt:44-59`).
- **Android 동적 렌더**: `AttentionTheaterHUD.kt` 도 동일 패턴 — 수평 Row vs 수직 LazyColumn(max 260dp) 분기. `AttentionFeatured` data class 에 `options`/`promptType`/`cursorIndex`/`navigable` 필드 추가하고 `buildAttentionFeatured()` 가 DashboardState 에서 그대로 실어 넘김.
- **Parser 커버리지 검증**: `bridge/src/__tests__/output-parser.test.ts` 에 3 종 fixture 추가 — (1) 5-옵션 plan approval (라벨 "Approve and start in auto mode" 등), (2) OpenClaw scope 선택 (user/project/session), (3) OAuth grant 2-버튼. 기존 `OPTION_NUMBERED` regex 가 이미 셋 다 cleanly 파싱해서 parser 수정은 불필요했다.

### 핵심 설계 결정
- **Popup 에 multi-step wizard 상태 기계를 새로 만들지 않는다.** Scope → Token → Submit 같은 hierarchical 흐름은 bridge 가 step 별로 `option_prompt` 이벤트를 emit 하므로 (각 step 에서 Claude Code TUI 가 다른 옵션 세트를 보여줌), popup 은 "현재 step 의 옵션 집합" 만 그리면 된다. UI 에 step chain 을 유지할 이유가 없다 — `state.options` 가 매번 교체될 뿐.
- **E-ink 는 popup 을 완전히 숨긴다.** 사용자 확인: "크리처 옆 물음표 그정도면 충분하다". 대안(static 배지, 2-option degraded 모드) 대신 완전 suppress 선택. e-ink 에서 입력을 받으려면 사용자는 태블릿/폰/Mac 으로 옮겨가면 되고, 크리처 "?" 가 이미 그 신호를 전달한다.
- **Fallback trio 는 유지한다.** 파서가 빈 options 배열과 함께 `AWAITING_PERMISSION` 을 emit 하는 edge case (라벨 추출 실패) 에서 popup 이 blank 가 되는 것을 막기 위해 Yes/No/Always 3버튼으로 degrade. 이전 하드코딩 경험이 누적되어 있는 defensive 안전망.
- **팔레트 이원화**: 수평 레이아웃은 index-based 색(green=0, red=1, cyan=2) 로 tool-approval 의 시각 기억을 유지하고, 수직 레이아웃은 semantic-based 색(recommended=green, deny=red, 기본=중립) 으로 긴 옵션 리스트에서 "default choice" 가 어디인지 즉시 보이게 했다.

### 검증
- `pnpm vitest run bridge/src/__tests__/output-parser.test.ts` — 200/200 통과 (신규 fixture 3건 포함).
- `xcodebuild -scheme AgentDeck_macOS` — BUILD SUCCEEDED.
- `xcodebuild -scheme AgentDeck_iOS -destination 'generic/platform=iOS Simulator'` — BUILD SUCCEEDED.
- `pnpm test:android` — 82/82 Android JUnit 통과.
- 실제 popup 동작(plan 5-option / OpenClaw scope / e-ink suppress) 은 다음 세션 또는 사용자 수동 QA — memory rule `feedback_verify_visual_output.md` 에 따라 screencap-verify 필요.

---

## 2026-04-19 — Integrations 재정리 (계정 vs API 키 두 그룹)

### 문제
Settings → Integrations 가 5개 통합(Claude / Codex / OpenClaw / Antigravity / Anthropic Admin) 을 한 줄로 늘어놓아 "토큰 없이 동작하는 것" 과 "토큰 입력이 필요한 것" 의 차이가 사라져 있었다. OpenClaw 행은 7가지 `gatewayAuthStatus` 분기마다 다른 help text 를 노출했고, 토큰 입력 필드는 `shouldShowOpenClawGatewayTokenEditor` 라는 복합 조건으로 들쭉날쭉 보였다. Onboarding `IntegrationsPane` 과 dashboard `SetupNeededCard` 가 같은 통합을 서로 다른 문구로 3중 노출. Antigravity 는 SQLite `antigravityUnifiedStateSync.modelCredits` 키에서 잔여 크레딧을 뽑을 수 있는데도 plan name 만 표시.

### 해결
- **단일 카탈로그**: `apple/AgentDeck/UI/Settings/IntegrationsView.swift` 신설. `IntegrationCatalog` (5개 descriptor) + `IntegrationStatusEvaluator` (DashboardState → IntegrationStatus 매핑) + `IntegrationRow` (3가지 mode: settings / onboarding / setupCard) + `IntegrationsView` (두 그룹 컨테이너).
- **두 그룹 분리**: `IntegrationKind.accountLinked` (Claude / Codex / OpenClaw / Antigravity — CLI 로그인이나 Web UI 페어링으로 자동 감지) vs `IntegrationKind.apiKey` (Anthropic Admin — 사용자 명시 입력). 그룹 헤더에 한 줄 설명을 붙여 "여기는 토큰 안 넣어도 됨" / "여기만 키 입력" 을 즉시 구분.
- **OpenClaw 7→3 상태 머지**: `connected` / `awaiting`(approval_pending · pairing_required · gateway_reachable · gateway_token_missing) / `failed`(auth_failed · token_mismatch · device_auth_invalid · unsupported_protocol). 토큰 필드는 Advanced disclosure 안으로 숨김 — App Store 페어링은 Keychain Ed25519 device key 로 default 동작하므로 99% 사용자에게 토큰은 불필요.
- **Antigravity 크레딧 노출**: `bridge/src/antigravity-local.ts:parseModelCredits` 를 Swift 로 포팅 (`UsageAPIClient.parseAntigravityModelCredits` + 4개 protobuf 헬퍼). 행에 "Google AI Pro · 1000 cr" 형태로 표시. 프로토콜/state 필드(`availableCredits`, `minimumCreditAmountForUsage`)는 이미 end-to-end 로 wired 되어 있었고 Swift 측 reader 만 비어 있던 상태였음.
- **세 화면 통일**: SettingsScreen.servicesContent, OnboardingSheet.IntegrationsPane, SetupNeededCard.setupNeededItems 가 모두 같은 catalog/evaluator 를 참조. SettingsScreen 에서 ~184줄 (codexAuthRow / openClawIntegrationRow / openClawGatewayHelpText / status label·color helpers / shouldShowOpenClawGatewayTokenEditor / anthropicAdminApiRow / latestAdminApiUsageSummary / formatTokenCount) 제거.

### 핵심 설계 결정
- **OpenClaw 토큰은 옵션이지 default 가 아니다.** `OPENCLAW_GATEWAY_TOKEN` 은 Gateway 가 shared-token 모드로 띄워졌을 때만 필요하고 일반 페어링은 OpenClaw Web UI 의 device approval 로 진행된다 (`OpenClawAdapter.swift:489-593`). UI 가 토큰 필드를 일등 시민처럼 노출하면 사용자는 "토큰을 어디서 받지?" 로 막힘 → Advanced 로 격리.
- **카탈로그 vs Settings 의 역할 분리**: catalog 는 cross-platform 으로 platform-agnostic (`#if os(...)` 없음). 플랫폼/샌드박스 specific UI (Keychain SecureField, file picker) 는 SettingsScreen 의 `accountIntegrationSlot` / `apiKeyIntegrationSlot` builder slot 으로 주입.
- **`SetupItem` 데이터 모델 유지**: SetupNeededCard 의 amber pulse / "SETUP" 헤더 시각 언어는 dashboard context 에 맞춤이라 IntegrationRow.setupCard mode 와 별개로 둠. derivation 만 evaluator 로 위임.

### 검증
- `xcodebuild -scheme AgentDeck_macOS` (App Store 빌드 플래그 포함) — BUILD SUCCEEDED
- `xcodebuild -scheme AgentDeck_iOS` — BUILD SUCCEEDED
- 실기기 / 페어링 시나리오 검증은 다음 세션 또는 사용자 수동: (a) Web UI 승인 → "Connected", (b) Keychain 비우고 재페어링, (c) Antigravity 크레딧 표시.

---

## 2026-04-19 — App Store daemon/CLI boundary hardening

### 문제
App Store 앱 + CLI daemon + 다른 기기 연동은 런타임상 대부분 동작했지만, 심사 기준으로 보면 경계가 흐렸다. App Store 빌드가 `.command` 스크립트를 만들어 Terminal/CLI를 실행하는 경로, "CLI 설치"로 읽히는 quota 안내, OpenClaw 승인 CLI 명령 안내, Node daemon의 `gatewayConnected`/`moduleHealth` 누락이 동시에 존재했다. Apple Review Guideline 2.5.2/4.2.3 관점에서는 앱이 companion executable 설치/실행을 기능 경로처럼 보이면 리젝 리스크가 있다.

### 해결
- **App Store Launch Session hardening**: `SessionLauncher` 는 `AGENTDECK_APP_STORE` 빌드에서 shell script / Terminal launch 를 만들지 않고 안내 알림만 표시. Claude Code는 사용자가 Terminal에서 직접 실행하고 AgentDeck는 opt-in hooks 로 감지한다. Codex/OpenCode PTY launch 는 App Store 빌드에서 unavailable.
- **CLI/helper copy 정리**: Setup card, menu bar RATE LIMITS, Settings Codex/OpenClaw/D200H, App Review notes, TestFlight checklist, feature matrix에서 "Install CLI", `.command` launch, bundled D200H helper, `openclaw devices approve` 중심 안내를 App Store-safe 문구로 교체.
- **Node daemon parity**: Node daemon state에 `gatewayConnected` / `moduleHealth` 를 추가하고 `/health`, `/status`, `/devices` JSON 진단을 맞췄다. OpenClaw virtual session / Pixoo creature / plugin slot 주입은 `gatewayAvailable` 이 아니라 인증된 `gatewayConnected` 기준으로 게이트.
- **ADB reverse parity**: Node ADB reverse는 Android well-known `tcp:9120` → 실제 daemon port 매핑으로 Swift와 맞춤.
- **App Store D200H helper 차단**: App Store 빌드는 자동 bundled-helper promotion 및 Settings helper UI를 숨기고 direct IOKit HID 경로만 남김.

### 검증
- `pnpm --filter @agentdeck/shared build` — 통과
- `pnpm --filter @agentdeck/bridge build` — 통과
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug ... SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG AGENTDECK_APP_STORE'` — 통과
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Release ... SWIFT_ACTIVE_COMPILATION_CONDITIONS='AGENTDECK_APP_STORE'` — 통과
- `bash apple/scripts/verify-appstore-archive.sh /tmp/AgentDeckDerivedDataAppReviewGuardRelease/Build/Products/Release/AgentDeck.app` — 통과

### 핵심 설계 결정
- App Store 앱은 **단독으로 쓸 수 있어야 하며** AgentDeck CLI/LaunchAgent 설치를 요구하지 않는다.
- 사용자가 이미 별도 terminal-managed daemon을 운영하는 경우에는 localhost/WebSocket client로 붙을 수 있지만, App Store UI가 설치나 기동을 유도하지 않는다.
- Claude Code 세션은 앱이 띄우지 않는다. 사용자가 직접 실행하고, AgentDeck는 명시적 hook 동의 후 들어오는 이벤트를 모니터링한다.

---

## 2026-04-19 — Anthropic Admin API usage + Android APK 재배포

### 문제
이전 세션에서 확정된 "App Store 빌드에서 Claude quota 못 읽는" 제약(Anthropic Feb 2026 정책)의 보조 기능. Admin API key 보유 유저는 구독 quota 대신 org-wide API 토큰 소비를 볼 수 있어야 함 — 소수 타겟이지만 sanctioned path. 또한 이번 세션 변경사항(gatewayConnected 게이팅)이 실기기에 반영 안 된 상태였음.

### 해결
**Android APK 재배포** — Pantone6 (`AA007422R24C1300039`) + Lenovo (`HVA095B4`). Debug 키스토어로는 signature mismatch → `./gradlew assembleRelease` 로 재빌드 후 `adb install -r` 양쪽 **Success**. CremaS 는 USB 미연결로 별건.

**Anthropic Admin API 기능**:
- `apple/AgentDeck/Daemon/Core/AnthropicAdminApiClient.swift` 신규 — Keychain store (service `bound.serendipity.agentdeck.dashboard.anthropic.admin-api-key`) + async `fetchUsage()`. `/v1/organizations/usage_report/messages` 를 1d 버킷 `group_by=model` 로 호출해 today/month TokenCounts (input/output/cacheRead/cacheCreation) + top-3 모델 집계
- `AgentState.swift` / `Protocol.swift`: `adminApiKeyPresent`, today/month 4종 필드, `adminApiTopModels: [AdminApiModelUsage]`, `adminApiFetchedAt`, `adminApiStale` 추가. WS broadcast 경유
- `DaemonServer.swift`: 10분 간격 polling (유저가 key 저장한 경우에만). `cachedAdminApiUsage` + `refreshAdminApiUsage()` + `buildUsageEvent` 에 직렬화
- `SettingsScreen.swift`: Services 섹션에 `anthropicAdminApiRow` — SecureField (Keychain 저장) + Save/Clear + 현재 값 요약. OpenClaw 토큰 패턴 재사용
- `ControlTowerPanel.swift`: RATE LIMITS 아래에 `anthropicApiUsageSection` — Today/30d 별 in/out/cache 토큰 + 상위 모델 2개. `adminApiKeyPresent == true` 일 때만 렌더, 구독 유저는 보이지 않음

**Non-App-Store fallback**: `currentKey()` 가 `ANTHROPIC_ADMIN_API_KEY` env var 지원 — CLI/dev 빌드는 Settings 안 건드리고 opt-in 가능.

### 검증
- `xcodebuild -scheme AgentDeck_macOS / AgentDeck_iOS build` — 양쪽 SUCCEEDED
- Android 2 대 APK 설치 성공
- Admin API 실제 호출은 유저가 key 저장 후 daemon 재시작해서 확인 필요 (수동 검증)

### 핵심 설계 결정
- **Admin API 는 별도 tier** — RATE LIMITS (Pro/Max quota) 와 시각적으로 분리. 같은 유저가 동시에 둘 다 볼 수 있음 (구독 + API key 병용 케이스)
- **Non-Keychain fallback** — env var 로도 접근 가능해야 CLI 유저도 혜택. Keychain 을 강제하지 않음
- **Polling 은 key 있을 때만** — 네트워크/배터리 절약. 5분 Anthropic 데이터 지연도 명시해서 "Awaiting first fetch" 상태 정상 표시
- **Top models 는 2개만 표시** — 카드 공간 제약. 3개 저장은 하되 렌더는 2개
- **Android 빌드는 항상 release** — debug 키스토어 signature 가 기존 설치와 다름. 재배포 시 assembleRelease 필수. 다음 세션에도 해당

---

## 2026-04-19 — Daemon discovery 통합 + session_push 핸들러 + UX refresh

### 문제

App Store macOS 앱 실행 후 터미널에서 `agentdeck claude` 를 띄우면 세션이 대시보드에 나오지 않았다. 실측 상태:
- Swift daemon: group container 의 `daemon.json` 에 port 9120 기록
- `~/.agentdeck/daemon.json`: 없음
- CLI 세션: `~/.agentdeck/sessions.json:9121` 으로 등록
- Swift daemon: `fetchUsageRelayed: 0 siblings`, `BROADCAST sessions_list: 0 sessions`

세 레이어 버그가 합쳐진 상태:
1. Node CLI 는 `~/.agentdeck` 만 봐서 group container 의 Swift daemon 을 못 찾음
2. Swift daemon 은 sandbox 로 `~/.agentdeck` 를 못 읽음
3. 세션 브릿지가 WS 로 보내는 `session_push_register` 를 Swift daemon 의 `handleCommand` 가 type 만 로그하고 버림 (핸들러 부재)

인접 이슈:
- `CreatureClaudeCode.imageset/claudecode.svg` 가 Google Antigravity 로고 + `<title>Antigravity</title>` 라벨. lobe-icons 에서 파일 착각
- Android + ESP32 + Pixoo 의 가재 크리처가 `gatewayAvailable` 기반 → 미인증 상태에서도 가재 표시
- App Store 빌드에서 Claude 사용량이 "No data" 만 뜨고 사유 불명 (OAuth 토큰 Anthropic 정책상 접근 불가)
- OpenClaw 토큰 입력 UI 가 특정 auth 상태에서만 노출 → 첫 실행 유저 진입 경로 없음

### 해결

**Daemon discovery 통합 (`bridge/src/session-registry.ts`)**
- `getCandidateDataDirs()` 신설. 우선순위: `AGENTDECK_DATA_DIR` → `~/.agentdeck` → macOS group container
- `readSessions` / `readDaemonInfo` / `findExistingDaemon` 이 후보 dir 순회. 쓰기는 자기 dir 만 (sandbox 경계 존중)
- `findDaemonPortAsync()` — registry miss 시 9120-9139 `/health` probe. Swift 의 `httpPort` 필드 respect

**세션 브릿지 async port provider (`daemon-ws-client.ts` + `index.ts`)**
- `portProvider` 가 async 반환 허용. `doConnect()` async 화. registry 비어있어도 probe 로 daemon 발견

**Hook shell snippet 3 경로 통합**
- `@agentdeck/hooks` 의 `buildHookCommand(event)` 가 단일 소스. `setup/src/setup.ts` inlined copy + `HookInstaller.swift` 가 byte-identical snippet emit
- 런타임 순서: `$AGENTDECK_PORT` → `~/.agentdeck/daemon.json` → group container → `httpPort`/`port` 선호 → `/health` probe → 9120 fallback

**Swift daemon session_push 핸들러 (`DaemonServer.swift`)**
- `pushedSessionsById` map 추가 — filesystem registry 와 분리
- `handleCommand` 최상단에 `session_push_register` / `session_push_state` 인터셉션 (Node daemon 의 `onRawMessage` 등가)
- `handleSessionPushRegister` → upsert + 즉시 `broadcastSessionsList()`
- `handleSessionPushState` → state/modelName 패치 + broadcast
- `refreshSessions()` 가 filesystem + pushed 병합 후 enrichSessionsWithState → probe 실패한 pushed 항목 자동 prune

**Crayfish 게이팅 통일 (4 플랫폼)**
- Mac `TerrariumState.swift`, Android `TerrariumState.kt` + `TopologyRail.kt`, ESP32 `agent_state.h/protocol.cpp/renderer.cpp/matrix_pages.cpp/hud_bar.cpp`, Pixoo `PixooRenderer.swift` + `PixooModule.swift` 전부 `gatewayConnected` 기반으로 통일
- Android Protocol 에 `gatewayConnected` 필드 신설
- ESP32 DashboardState 에 `bool gatewayConnected` 추가 + state_update JSON 파싱

**macOS Dashboard UX refresh**
- `SetupNeededCard` 신규 — Claude quota / OpenClaw token / hook consent 미설정 항목을 한 카드로 + "Open Settings →" 바로가기
- `AttentionTheaterHUD` + `TopologyRail` (`TankStatusPanel` + `DeviceDiagnosticPanel` 대체)
- `ControlTowerPanel.rateLimitsSection` — "No data" → 상태별 actionable 메시지
- `SettingsScreen` — OpenClaw 토큰 편집기 App Store 빌드에서 상시 노출 + Attribution 링크

**브랜드/IP 정리**
- `CreatureClaudeCode` SVG → lobe-icons Claude 아이콘 (`<title>Claude</title>`)
- `ATTRIBUTION.md` 신규: lobe-icons MIT + 상표 고지 + 비관계 선언
- `README.md` + `docs/appstore-metadata-draft.md` 에 non-affiliation disclaimer (App Store 5.2.5 대비)

### 검증
- `pnpm -r build` — Done (bridge + hooks + setup + shared + plugin)
- `xcodebuild -scheme AgentDeck_macOS / AgentDeck_iOS build` — SUCCEEDED
- `pnpm vitest run bridge/src` — 703/703 pass
- ESP32 4 보드 (round_amoled / ips_35 / ulanzi_tc001 / box_86) 전부 SUCCESS
- Android `./gradlew :app:assembleDebug` — BUILD SUCCESSFUL
- Runtime 로그 확인: `[Daemon] session_push_register: <id> port=9121 agent=claude-code` 발화 + `[D200H] BROADCAST sessions_list: 0 → 1 sessions`

### 핵심 설계 결정

- **Option A: Mac 앱이 daemon hub 우선** — Mac 앱 있으면 Swift daemon 이 9120 소유. CLI `agentdeck daemon start` 는 감지 후 exit. `agentdeck claude` 는 세션 bridge 만 스폰해서 기존 daemon 에 붙음. Mac 앱 없는 유저만 `agentdeck daemon install` (LaunchAgent)
- **Write-strict / read-lenient** — 각 프로세스는 자기 소속 dir 에만 쓰고 (Swift=group container, Node=~/.agentdeck) read 만 cross-dir. sandbox 경계 불침범 + 시야 확장
- **WS-push 를 1급 discovery path 로 격상** — Swift 는 `~/.agentdeck/sessions.json` 을 영구적으로 못 읽음. filesystem 동기화 대신 `session_push_register` 를 authoritative registration mechanism 으로. Idempotent 재등록 + `/health` probe 실패 시 prune
- **Crayfish 는 가용성이 아니라 인증의 signal** — `gatewayAvailable` 는 "프로세스 reachable" 의미로 topology row 에만. 크리처는 `gatewayConnected` 트리거. 4 플랫폼 동일 규칙
- **App Store 에서 Claude subscription quota 읽기는 합법 경로 없음** — Anthropic 2026-02-20 정책: subscription OAuth 를 3rd-party 툴에 쓰면 ToS 위반. Setup 카드가 "Install AgentDeck CLI" 로 정직하게 안내
- **Option A 는 CLI-only 배포와 호환** — Mac 앱 없는 유저는 기존 `agentdeck daemon start/install` 경로 그대로 작동. "Mac 앱 있으면 우선" 이지 "없으면 불가" 가 아님

---

## 2026-04-18 — macOS App Store runtime stabilization

### 문제
Xcode run 상태의 App Store-gated macOS 앱에서 기본 daemon/UI는 동작했지만 런타임 상태 노출이 여러 지점에서 어긋났다. 실제 daemon은 fallback port `9121`에 떠 있는데 일부 UI는 `9120`을 표시했고, MLX 서버가 3.5/3.6 catalog를 모두 반환해도 Swift daemon은 정렬된 첫 모델인 3.5만 선택했다. OpenClaw Gateway는 살아 있었지만 `AUTH_TOKEN_MISSING`을 generic re-approve 상태로 보여줬고, App Store 빌드에서도 외부 `adb` 경로 탐지 때문에 ADB가 available처럼 보였다.

### 해결
- Swift `DaemonServer.probeMLX()`를 shared TS `pickMlxModel` 정책과 일치시킴: pin > `mlx-community/Qwen3.6-35B-A3B-4bit` > first > nil. 선택 모델(`mlxModels`)과 감지 catalog(`mlxModelCatalog`)를 분리해 state/usage 이벤트에 실음.
- daemon state에 실제 `daemonPort`를 추가하고 `TopologyRail`/`UnifiedGraphView`가 hardcoded `9120` 대신 실제 bound port를 표시하게 변경.
- App Store OpenClaw 경로에 Gateway token Keychain store 추가. Settings에서 `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` 값을 저장/삭제할 수 있고, 저장 시 daemon을 재시작해 `connect.params.auth.token`으로 재시도한다. `AUTH_TOKEN_MISSING`은 `gateway_token_missing`으로 분류.
- App Store ADB는 외부 binary discovery를 하지 않고 `available=false`, `disabled=true`로 보고한다. CLI/non-App-Store fallback은 유지.
- ESP32 serial은 동일 Wi-Fi provision payload를 같은 port에 반복 전송하지 않고, nonblocking partial write는 retry loop로 끝까지 쓰며 write failure는 per-port backoff 상태에 남긴다.
- App Store entitlement에 `com.apple.security.files.user-selected.read-write`와 `com.apple.security.files.bookmarks.app-scope` 추가.
- local macOS release export가 `apple/ExportOptions-macOS.plist`를 쓰도록 `scripts/build-apple-release.sh` 수정.

### 검증
- `swiftc -parse` on changed Swift files
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataRuntimeStabilization build CODE_SIGNING_ALLOWED=NO` — 성공
- `xcodebuild -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataRuntimeStabilizationNoDebugDylib build CODE_SIGNING_ALLOWED=NO ENABLE_DEBUG_DYLIB=NO` — 성공
- `bash apple/scripts/verify-appstore-archive.sh /tmp/AgentDeckDerivedDataRuntimeStabilizationNoDebugDylib/Build/Products/Debug/AgentDeck.app` — 성공
- `git diff --check` — 성공

---

## 2026-04-18 — Claude usage stale truthfulness + OpenClaw 미인증 세션 숨김

### 문제
App Store-gated macOS 앱에서 Claude usage가 실제로는 OAuth token missing + 오래된 `usage-cache.json` fallback인데도 `oauthConnected=true`와 현재 `fetchedAt`처럼 노출됐다. 결과적으로 UI/D200H가 stale Claude quota를 정상 live usage로 오해하게 만들었다. OpenClaw도 Gateway port가 reachable이면 인증 실패(`gateway_token_missing`) 상태에서도 `openclaw-gateway` virtual session을 주입해 가재 크리처가 연결된 것처럼 보였다.

### 해결
- `ApiUsageData`에 `fetchedAt`/`stale` metadata를 추가하고, stale file-cache fallback은 `usageStale=true`, 원본 cache timestamp, `oauthConnected=false`, `tokenStatus=missing`으로 그대로 노출한다.
- `/usage`가 stale cache를 fresh fetch처럼 포장하지 않도록 `lastApiFetchTime`을 cache의 실제 `fetchedAt`으로 유지한다. stale 상태에서는 reset timestamp를 event에서 숨겨 오래 지난 reset time이 UI에 남지 않게 했다.
- `AgentStateHolder`가 stale usage event를 받으면 기존 reset time을 fallback으로 보존하지 않고 nil로 비운다.
- OpenClaw virtual `openclaw-gateway` session은 `cachedGatewayConnected == true`인 경우에만 sessions_list에 주입한다. `gatewayAvailable`/`gateway_token_missing`은 topology/status row에만 남고 terrarium/D200H 크리처는 나오지 않는다.
- OpenClaw Gateway token Settings copy를 정리했다. token은 AgentDeck token이 아니라 사용자가 Gateway에 설정한 shared secret(`OPENCLAW_GATEWAY_TOKEN` 또는 `gateway.auth.token`)이며, token-required 상태일 때만 입력 UI를 강조한다.
- D200H usage wide button도 stale cache일 때 `USAGE STALE` / `cached Claude usage`로 표시하고 gauge 색을 회색화한다.
- shared TS protocol의 `gatewayAuthStatus` union에 `gateway_token_missing`을 추가했다.

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug build` — 성공
- Runtime `/usage`: `oauthConnected=false`, `usageStale=true`, `tokenStatus=missing`, `fetchedAt=1776480311640`, `mlxModels=["mlx-community/Qwen3.6-35B-A3B-4bit"]`
- Runtime `/status`: `sessions`는 daemon 1개만 포함, `modules.gateway.authStatus="gateway_token_missing"`, `gateway.connected=false`; 미인증 OpenClaw virtual session 없음
- Runtime `/status`: D200H `sessionsCount=0`, `lastStateHash`에 OpenClaw slot text 없음
- Runtime `/health`: ADB `available=false`, `disabled=true`; Pixoo/D200H/serial 상태는 계속 노출됨

---

## 2026-04-18 — Port drift resilience + MLX 4-layer picker + reset-time grace

### 문제
사용자 Dashboard에 동시다발 3가지 증상. (1) 디바이스들이 연결/해제 반복 + 일부는 끊어진 채 고정. (2) Tank Status 5h 리밋이 "now"로 박혀 표시. (3) MLX 모델이 3.6 아닌 3.5로 표시. 실상 확인 결과 Xcode Debug `AgentDeck.app`(pid 69905)이 9120에 bind 못하고 9121만 LISTEN + `~/.agentdeck/daemon.json` 부재 상태. **daemon hub 부재가 공통 뿌리**로 판단. 사용자 피드백: "9120 아니어도 다른 port로 떠서 모든 consumer가 정확한 상태 노출해야 한다."

### 해결
세 갈래 독립 수정, in-progress UI 리팩토링(AgentStatusIcon/ControlTowerPanel 989줄 등)은 사용자 scope로 보존.

- **Bridge port drift (`c6bbd1e8`)**: `DaemonWsClient`에 `portProvider: () => number | null` 추가. `connect()`가 null도 수용해 backoff loop에서 provider 재호출 → daemon이 나중에 뜨거나 fallback port로 옮겨도 session bridge가 자동 추종.
- **MLX 4-layer picker (`f8c37ab2`)**: `shared/src/llm-settings.ts`에 순수함수 `pickMlxModel(catalog, pin)` — pin-in-catalog > `MLX_FALLBACK_MODEL`-in-catalog > first > null. `mlx-probe.ts` + Swift `TimelineSummarizer` 모두 이걸 사용. **App Store 신규 설치** 사용자 (mlx-vlm 미설치)는 null 반환 → `queryMLX` HTTP 호출 자체를 skip, 10s timeout 블로킹 제거.
- **FormatUtils grace (`338e5a1a`)**: `formatResetTime(iso, graceSeconds: 3600)` — `remaining <= 0 && remaining >= -grace` 이면 "now", 그보다 과거면 nil(UI 숨김). `adjustUsagePercent` 1h grace 정책과 통일.

### 핵심 설계 결정
- **daemon port는 authoritative value가 아니라 dynamic**. 9120은 default이지만 bind 실패 시 fallback(`findAvailablePort`) → `daemon.json`에 실제 port 기록. 모든 consumer(bridge session, plugin, ESP32)는 daemon.json 또는 mDNS로 매 connect마다 재조회. Bonjour TXT + mDNS는 이미 port-agnostic. Plugin `connection-manager.setPortProvider`도 기존 구조. 브리지 session bridge가 마지막 취약점이었음 — 해결.
- **MLX "Not detected"는 UX-level로 구분**해야 함. `resolveMlxModel(probe)`는 CLI 경로용(MLX 설치 전제, 항상 string 반환), `pickMlxModel(catalog, pin)`는 UI/probe 경로용(nullable). 둘은 공존. Swift TimelineSummarizer는 후자를 미러링 — App Store 정책(Apple 2.5.2, subprocess 금지)상 앱이 MLX 자동 기동 불가이므로 null 경로가 정상 상태.
- **포맷터는 FormatUtils 단일 구현**. `TopologyRail.compactReset`, `ControlTowerPanel.formatResetTime` private 중복은 graceSeconds 파라미터를 받는 global `formatResetTime`으로 수렴 (ControlTowerPanel 쪽 통합은 사용자 in-progress 리팩토링 합병 시 함께 들어갈 예정).
- **in-progress 변경 존중 원칙**: `OpenClawAdapter.clientId` "agentdeck-dashboard" → "gateway-client" 변경, `AgentStatusIcon` 0.55s pulse 타이머 전환, `ControlTowerPanel` 989줄 리팩토링은 모두 사용자의 의도적 작업이라 scope 밖. 증상 재현 시 이것들도 후속 조사 대상.

---

## 2026-04-18 — Pixoo64 OFFLINE frame on Swift daemon stop

### 문제
Swift 인프로세스 데몬이 종료되면 ESP32/D200H/CremaS 는 펌웨어/앱 쪽에서 "Waiting / Reconnecting" 화면으로 전환되지만, Pixoo64 는 stateless HTTP 디바이스라 누구도 프레임을 안 보내면 마지막 크리처 장면에 그대로 얼어붙는다. 사용자 입장에서는 데몬이 죽었는데도 크리처가 살아있는 듯 보여 혼란.

### 해결
Node 브리지의 `stopPixooBridge()` 패턴을 Swift 로 포팅:
- `PixooRenderer.renderDisconnectedFrame()` — 64×64 검정 + 중앙 `#555555` "OFFLINE". 필요한 A–Z 글리프 6자(O/F/F/L/I/N/E)만 `pixelFont` 에 추가, 신규 폰트 시스템 없음.
- `PixooModule.stop()` — render/probe task cancel + await 이후 `pushOfflineFrame()` 호출. 2초 전체 cap (deadline Task 로 `pushes.cancel()`). backoff 상태 device 는 제외.
- `PixooModule.pushFrame()` — `cachedState == "disconnected" && cachedAgentType == nil && cachedSessions.isEmpty` 일 때도 동일 프레임 사용. 앱 부팅 직후 세션 없는 구간의 "빈 수조" 가 아닌 중립 placeholder 표기.

### 핵심 설계 결정
- **크래시(SIGKILL) 시나리오는 스코프 밖** — Swift 쪽 정리 코드가 실행될 기회가 없음. 해결책은 firmware watchdog 이 필요하므로 별건으로 분리.
- **밝기 0 대신 "OFFLINE" 텍스트 선택** — 전원 고장 오해 방지 + Node 브리지 동작과 일관.
- **pushOfflineFrame 은 2s cap 강제** — `pushToDevice` 의 PicID 재동기화가 2번 HTTP 호출로 번질 수 있어(4s worst case) 데몬 shutdown 지연 방지를 위해 Task 레벨에서 cap.

---

## 2026-04-18 — OpenClaw Gateway-native App Store reactivation

### 목표
`a2b2dbfe` 의 App Store `return nil` 차단은 외부 파일/CLI/subprocess 경로 제거에는 맞았지만, OpenClaw 자체를 "unsupported" 로 고정했다. 이번 작업은 해당 차단을 삭제하지 않고 **App Store 빌드에서만 Gateway WebSocket RPC 경로로 대체**한다. CLI/Homebrew 빌드는 기존 `~/.openclaw/identity/*` + `openclaw ...` fallback 을 계속 primary 로 유지한다.

### 구현
- `shared/src/gateway-protocol.ts` 를 OpenClaw 2026.4.14 surface 에 맞춰 확장: `hello-ok.auth.deviceToken`, `health`, `models.list`, `logs.tail`, `sessions.subscribe`, `sessions.messages.subscribe`, `sessions.changed`, `session.message`, `session.tool`, `system-presence`.
- parity fixtures 추가 후 `pnpm generate-protocol` 로 `generated/protocol/GatewayFrame.*` / schema 갱신.
- `OpenClawAdapter` App Store 경로:
  - Keychain service `bound.serendipity.agentdeck.dashboard.openclaw.identity`
  - `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
  - Access Group 없음
  - self-generated Ed25519 keypair, `deviceId = sha256(raw 32-byte public key).hex`
  - v3 payload `v3|deviceId|clientId|clientMode|role|scopesCSV|signedAtMs|token|nonce|platform|deviceFamily`
  - `hello-ok.auth.deviceToken` 저장 + reconnect 재사용
- App Store `models.list` / `health` / `logs.tail` 은 Gateway RPC 로 대체. `BridgeLogStream` 의 subprocess tailer는 non-App-Store 전용으로 유지.
- `DaemonServer` 에 OpenClaw auth/pairing 상태 캐시 추가: `gateway_not_found`, `gateway_reachable`, `pairing_required`, `approval_pending`, `connected`, `auth_failed`, `token_mismatch`, `device_auth_invalid`, `unsupported_protocol`.
- Settings / Tank status copy 를 "Unavailable" 에서 pairing-aware 안내로 전환. App Store identity 는 기존 CLI identity 와 분리되며, 사용자는 `openclaw devices list` / `openclaw devices approve <requestId>` 또는 Web UI(`http://localhost:18789`)에서 승인한다.

### 검증
```
pnpm --filter @agentdeck/shared typecheck
pnpm vitest run bridge/src/__tests__/gateway-parity-fixtures.test.ts
xcodebuild -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination platform=macOS,arch=arm64 build
```

---

## 2026-04-18 — Apple 2.5.2 compliance (AGENTDECK_APP_STORE compile flag + subprocess strip)

### 동기
직전 커밋 `797b6186` 의 APP_REVIEW_NOTES.md 는 "subprocess 없음" 에 가깝게 설명했지만 실제 macOS 빌드는 여전히 (a) `copy-adb.sh` 가 `Contents/Helpers/{adb,node,agentdeck-d200h-helper}` + `Contents/Resources/agentdeck-runtime/bridge/dist/cli.js` 를 번들하고, (b) 9개 파일에 `Process()` 경로 (openclaw/whisper/network/security/sqlite3/env/sh) 가 잔존했다. Apple Review Guideline 2.5.2 는 self-contained 를 요구하고 외부 코드 실행/설치에 민감하므로 이 상태로는 rejection 리스크가 크다. 동시에 CLI/Homebrew 배포에서는 이 자산들이 정당한 기능이라 **컴파일 스위치로 분리** 하는 게 맞다.

### Stage 1 — `AGENTDECK_APP_STORE` 컴파일 플래그
- `project.yml` `AgentDeck_macOS.settings.base.SWIFT_ACTIVE_COMPILATION_CONDITIONS: AGENTDECK_APP_STORE` 추가. Debug/Release 둘 다 자동 적용.
- `apple/scripts/copy-adb.sh` 시작부 조건부 early-exit: `[[ "$SWIFT_ACTIVE_COMPILATION_CONDITIONS" == *AGENTDECK_APP_STORE* ]] && exit 0`. App Store 빌드에서 번들 자산 복사 skip.
- CLI/Homebrew 빌드는 `SWIFT_ACTIVE_COMPILATION_CONDITIONS=""` 로 override 해서 동일 스킴 재사용.

### Stage 2 — SessionLauncher 경로 축소
`#if !AGENTDECK_APP_STORE` 가드:
- `.bundledBridge` 케이스 resolution 블록 (기존 dead code — `findBundledNode()` 가 `Resources/node` 찾는데 `copy-adb.sh` 는 `Helpers/node` 에 배치함)
- `findBundledBridge()` / `findBundledNode()` — App Store 빌드에서 `return nil` 스텁으로 교체
- `TerminalApp.iterm` AppleScript 분기 (`openInITerm`)
- Terminal AppleScript fallback (`openInTerminalViaAppleScript`)
- AppleScript 경로 전부 제거됨 → NSAppleEventsUsageDescription 불필요 + Apple Events entitlement 불필요

### Stage 3 — 9개 파일 Process() 가드/교체
- `OpenClawAdapter.fetchModelCatalog` / `resolveOpenClawBin` / `openClawEnvironment`: 전부 `#if AGENTDECK_APP_STORE → return nil`
- `AdbModule.runProcess` / which-adb fallback: App Store 에서 `(nil, Data())` 반환
- `DaemonVoiceAssistant.transcribeViaCLI`: whisper-cli spawn compile-out
- `BridgeLogStream.start` + `resolveOpenClawBin`: openclaw logs follow 전면 skip
- `GatewayProbe.checkHealth`: openclaw doctor spawn 제거, TCP probe 만
- `WifiConfig.detectCurrentSSID` / `getKeychainPassword`: networksetup/security subprocess 제거 (ESP32ProvisionSheet 가 수동 입력으로 대체)
- `UsageAPIClient.getOAuthCredentials` / `sqliteValueViaShell`: `/usr/bin/security` + `/usr/bin/sqlite3` 제거
- `DaemonService.startBundledD200HHelper`: App Store 에서 명시적 early-return + user message ("bundled helper is CLI-only; use direct IOKit HID")
- `ESP32Serial.detectPorts`: `shellSync("ls /dev/cu.usb*")` 를 **무조건** `FileManager.contentsOfDirectory(atPath: "/dev")` 로 교체 (CLI 빌드에도 개선). `shellSync` 헬퍼 자체는 CLI 빌드 전용.

### Stage 5 — 신설 `apple/scripts/verify-appstore-archive.sh`
자동 검증 스크립트 신설. 5가지 invariant:
1. `Contents/Helpers/{adb,node,agentdeck-d200h-helper}` + `Contents/Resources/agentdeck-runtime` 부재
2. 메인 AgentDeck Mach-O 외 embedded executable 부재
3. 메인 바이너리 `strings` scan 에 `/usr/bin/env`, `/bin/sh`, `/usr/bin/security`, `/usr/bin/sqlite3` 부재
4. Info.plist `LSRequiresIPhoneOS` 부재 (macOS bundle 에 한정)
5. `codesign -d --entitlements :-` 출력에 `home-relative-path` 문자열 부재

플랫폼 자동 감지: `Contents/` 존재 → macOS / 없으면 iOS. iOS bundle 은 LSRequiresIPhoneOS 정상 허용하고 나머지 invariant 만 적용.

CI wiring: `.github/workflows/apple-release.yml` 의 macOS + iOS 양쪽 Archive 단계 직후 invocation.

### Stage 6 — APP_REVIEW_NOTES 재작성
- "Subprocess execution": 이 빌드가 `AGENTDECK_APP_STORE` 컴파일 조건으로 모든 `Process()` 지점을 compile-out 한다는 것과, 유일한 "launch program" 경로가 `NSWorkspace.open(URL)` on user-initiated `.command` 파일 (사용자가 Launch Session 클릭) 이라는 것을 정확히 기술.
- Claude Code 훅 명령의 `python3/curl` 은 **앱이 spawn 하는 게 아니고** Claude Code 자체 런타임이 사용자 터미널에서 실행함을 명확히.
- Codex/OpenCode Launch Session shortcut 은 companion CLI (`npx @agentdeck/setup`) 필요함을 honest 하게 공시.

### 실측 검증
```
xcodebuild -scheme AgentDeck_macOS -configuration Release → BUILD SUCCEEDED
note: AGENTDECK_APP_STORE build — skipping bundled adb/node/helper (Apple 2.5.2)
.app/Contents/Helpers/ → 디렉토리 자체 부재
strings Contents/MacOS/AgentDeck | grep '^/usr/bin/(env|security|sqlite3)|^/bin/sh' → 0건
verify-appstore-archive.sh .app → ✓ passes (macos)
```

### 후속 (Post-a2b2dbfe 마무리, 본 항목)
- `SessionLauncherTests.testFallsBackToBundledBridgeWhenInstalledBridgeMissing` 를 `#if !AGENTDECK_APP_STORE` 로 감싸 App Store 빌드에서 컴파일아웃 (호스트 앱 enum case 는 유지되나 함수 body 분기가 없어 모드 불일치). `AgentDeckTests_macOS` 타겟에도 동일 flag 추가해서 `#if` 평가가 일치하도록.
- `apple-release.yml` iOS archive 뒤에 verify-appstore-archive.sh 호출 단계 추가 (macOS 는 이미 있음).
- iOS Onboarding Pane 3 의 "Scan QR in Settings" 텍스트 → 직접 `QRScannerView` fullScreenCover 버튼으로 업그레이드 (첫 실행 페어링 경로 1단계 단축).

### 파일 변경 (a2b2dbfe 커밋)
- 수정: `project.yml`, `scripts/copy-adb.sh`, `SessionLauncher.swift`, `DaemonService.swift`, `UsageAPIClient.swift`, `ESP32Serial.swift`, `AdbModule.swift`, `WifiConfig.swift`, `OpenClawAdapter.swift`, `GatewayProbe.swift`, `BridgeLogStream.swift`, `DaemonVoiceAssistant.swift`, `APP_REVIEW_NOTES.md`, `apple-release.yml`
- 신설: `apple/scripts/verify-appstore-archive.sh`

### 주의 (후속 작업 위임 사항)
`OpenClawAdapter` / `GatewayProbe` / `BridgeLogStream` 의 App Store 빌드 현재 상태는 `return nil` 스텁. 이를 "Gateway-native operator client" 로 재활성화하는 작업은 **Codex 에게 별도 트랙으로 위임** (플랜 파일 Addendum v3 의 OpenClaw Gateway 재연동 스펙 참조). 해당 트랙이 랜딩되면 Settings 의 `openClawIntegrationRow` 카피도 "Unavailable" → "Pairing state" 로 전환 예정.

---

## 2026-04-18 — Broader Consumer 출시 준비 (Phase 1~3, macOS/iOS 동시)

### 목표
플랜(`~/.claude/plans/appstore-daemon-tranquil-anchor.md`) 의 "Broader consumer aim + macOS/iOS 동시 출시" 결정에 따라 Phase 1 (Core Consumer UX) + Phase 2 (Network Device UX) + Phase 3 (App Store 제출 준비) 를 일괄 실행.

### Phase 1 — Core Consumer UX

**1.1 Onboarding Sheet (macOS + iOS)**
- 신설 `apple/AgentDeck/UI/Onboarding/OnboardingSheet.swift` (macOS 3-pane sheet, 640×540) + `OnboardingScreen.swift` (iOS full-screen).
- Pane 1 (Welcome): "Stop Chatting. Start Steering." 타이틀 + 브랜드 카피.
- Pane 2 (Agent picker): Claude Code / Codex / OpenCode 설치 가이드 URL 버튼.
- Pane 3: macOS = iPad 페어링 안내 + iOS App Store 링크 / iOS = Mac 찾기 안내.
- `AppPreferences.hasSeenOnboarding` 으로 idempotent. `AgentDeckApp.swift` 에서 macOS `.sheet` + iOS 조건부 뷰로 연결. xctest 환경 자동 bypass (XCTestConfigurationFilePath env 체크).

**1.2 Claude Code CLI 설치 UX**
- `SessionLauncher.showAgentInstallPrompt(agent:)` — 기존 clipboard-copy NSAlert 를 "Open Installation Guide" (NSWorkspace 로 공식 docs URL) + "Check Again" (`isClaudeInstalled()` 재호출) + "Cancel" 3-button 으로 교체.
- Install guide URL 매핑: Claude Code → docs.claude.com, Codex → github.com/openai/codex-cli, OpenCode → opencode.ai/docs.

**1.3 APME Reports 빈 상태**
- `bridge/src/apme/dashboard-html.ts` 의 runs=0 empty state 를 rich onboarding 카드로 교체 (아이콘 + "No APME reports yet" + Quick Start 3-step).

**1.4 Stream Deck+ 감지/안내**
- `ControlTowerPanel.swift` 에 `StreamDeckDetection` 구조체 — `NSWorkspace.urlForApplication(bundleIdentifier: "com.elgato.StreamDeck")` + IOHIDManager VID `0x0FD9` 매칭 (매니저 open 안 함 → USB 권한 불필요).
- 5초 캐시 + Timer 로 갱신. 3-state UI: hw+no app → "Install Stream Deck software" + Download, hw+app → "Install AgentDeck plugin" + bundled `.streamDeckPlugin` fallback, neither → hidden.

**1.5 QR 페어링 (macOS show + iOS scan)**
- 신설 `apple/AgentDeck/UI/Pairing/QRPairingWindow.swift` — `CIFilter.qrCodeGenerator()` 로 `AuthManager.getWsUrl(port:)` 을 280×280 QR 로 렌더. URL text-select + "Copy URL" 버튼. `Window("Pair iPad or iPhone", id: "pairing-qr")` scene 신설.
- 신설 `QRScannerView.swift` (iOS) — AVFoundation UIViewControllerRepresentable + `AVCaptureMetadataOutput`. 카메라 권한 플로우 (거부 시 "Open Settings" 버튼). Swift 6 `@preconcurrency AVCaptureMetadataOutputObjectsDelegate`.
- ControlTowerPanel 액션바 "Pair iPad" 버튼. SettingsScreen iOS "Scan QR to Pair" 버튼 + `.fullScreenCover` + URL 유효성 검증 (ws/wss scheme + host).

### Phase 2 — Network Device UX

**2.1 ESP32 Wi-Fi Provisioning**
- 신설 `apple/AgentDeck/UI/Settings/ESP32ProvisionSheet.swift` (480×440) — 4-step 플로우 (detect/credentials/sending/result). 포트 탐지: `/dev/cu.usbserial-*`, `cu.wchusbserial*`, `cu.usbmodem*`. 시리얼 쓰기: Darwin POSIX `open(O_RDWR|O_NOCTTY|O_NONBLOCK)` + `cfmakeraw` + `cfsetispeed/ospeed(115200)` + `write`. JSON 프레임 `{"type":"wifi.config","ssid":..., "password":...}\n`.
- 샌드박스에서 Process spawn 불가하므로 networksetup / Keychain 자동 채움 포기 — 수동 입력으로 MVP.

**2.2 Pixoo Add UI**
- 신설 `apple/AgentDeck/UI/Settings/PixooSheet.swift` (520×520) — IP 입력 + "Test Connection" (`POST http://{ip}:80/post {"Command":"Device.GetDeviceList"}` 3s timeout) + 리스트 add/remove. `AgentDeckPaths.settingsJson` 의 `pixooDevices` 배열 원자적 병합.
- Bonjour 미지원 하드웨어라 수동 IP 입력 MVP. "Changes take effect after daemon restart" 안내.

**2.3 Settings Hardware GroupBox**
- `SettingsScreen.swift` 에 신규 `hardwareContent` + `hardwareRow(icon:title:subtitle:buttonLabel:action:)` 헬퍼 + macOS `GroupBox("Hardware Setup")` 섹션. ESP32/Pixoo 엔트리 포인트 + D200H plug-and-play 안내. DisclosureGroup collapse 는 post-launch 로 연기.

### Phase 3 — App Store 제출 준비

**3.2 APP_REVIEW_NOTES consumer aim**
- "Works standalone on Mac; iPad companion free; optional hardware configurable via in-app UI" 포지셔닝.
- Stream Deck+ Elgato 의존성 공시. iOS companion 설명 (QR 페어링, Bonjour). Review demo 시나리오 업데이트 (첫 실행 온보딩 → Launch Session → Pair iPad → Hardware Setup).

**3.4 CI workflow**
- `apple-release.yml` build-macos 잡은 `if: false` 유지 + 사전 요건 명시 주석 (Mac Installer Distribution 인증서, macOS Provisioning Profile, `ExportOptions-macOS.plist`, app group 등록). 자료 확보 후 `if: true` + release.needs 추가.

**3.1 / 3.3 (사용자 액션)**
- App Store Connect 메타데이터/스크린샷, TestFlight 배포 — 사용자 ASC 권한 필요. 태스크에 실행 방법 문서화.

### 검증
- `xcodebuild AgentDeck_macOS Debug build` → BUILD SUCCEEDED.
- `xcodebuild AgentDeck_iOS Debug build` → BUILD SUCCEEDED.
- 단위 테스트는 SX-state AgentDeck.app zombie (PID 85557) 로 테스트 러너가 연결 실패 — 환경 이슈 (코드 회귀 아님, 메모리 `xctest-zombie-blocker.md` 참조). Zombie 제거 후 재실행 시 통과 예상.

### 파일 변경
- **신설 (6)**: `UI/Onboarding/{OnboardingSheet,OnboardingScreen}.swift`, `UI/Pairing/{QRPairingWindow,QRScannerView}.swift`, `UI/Settings/{ESP32ProvisionSheet,PixooSheet}.swift`.
- **수정**: `App/{AgentDeckApp,AppPreferences}.swift`, `Daemon/Core/SessionLauncher.swift`, `Daemon/Apme/ApmeSettings.swift` (mlxCache `nonisolated(unsafe)` 추가), `UI/MenuBar/ControlTowerPanel.swift` (StreamDeckDetection + Pair iPad 버튼), `UI/Settings/SettingsScreen.swift` (Hardware GroupBox + iOS QR 스캔), `bridge/src/apme/dashboard-html.ts` (empty state), `apple/APP_REVIEW_NOTES.md`, `.github/workflows/apple-release.yml` (주석).

---

## 2026-04-18 — MLX Atomic Model Pin (single source of truth)

### 문제
Dashboard (macOS ControlTowerPanel `mlxRow`) 에 MLX 모델이 두 개 나란히 표시:
`mlx-community/Qwen3.5-35B-A3B-4bit, mlx-community/Qwen3.6-35B-A3B-4bit`.

실측:
- `mlx_vlm.server` PID 한 개가 127.0.0.1:8800 리슨 (wrapper.sh 자식들은
  `> >(ts_pipe ...)` process substitution 서브셸 — 정상).
- `/v1/models` 가 `~/.cache/huggingface/hub/` 에 내려받힌 두 버전을 **모두**
  advertise.
- AgentDeck 의 MLX probe (TS `mlx-probe.ts`, Swift `DaemonServer.probeMLX`) 가
  `nanollava` 만 빼고 그대로 `state.mlxModels` 에 broadcast.
- `bridge/src/timeline-summarizer.ts` (3 곳) + Swift `TimelineSummarizer` +
  `plugin/src/label-summarizer.ts` 가 `Qwen3.5-35B-A3B-4bit` 를 **하드코딩**.
- APME judge 만 `/v1/models` 자동탐지로 첫 결과를 잡아 썼기 때문에
  **summarizer 와 judge 가 서로 다른 모델로 동작할 수 있는 상태**.
- Settings 저장소에는 placeholder `"qwen3-30b"` 만 존재, 실제 모델 id 와 무관.

### 해결
**Single source of truth: `~/.agentdeck/settings.json` → `llm.mlx.{endpoint,model}`**

- `shared/src/llm-settings.ts` (신규): `loadMlxSettings()`,
  `resolveMlxModel(probeFirst)`, `mlxChatUrl()`, `MLX_FALLBACK_MODEL`. 30s TTL
  캐시. Placeholder (`""`, `"default"`, `"qwen3-30b"`) 는 모두 unset 으로 간주.
  Legacy fallback: `apme.judge.{endpoint,model}`.
- `apple/AgentDeck/Daemon/Apme/ApmeSettings.swift` 에 `LlmMlxConfig` +
  `loadMlxConfig()` 미러 (30s 캐시, `nonisolated(unsafe)` 로 Swift 6
  concurrency 통과).
- **Probe 필터**: TS `fetchMlxModels(pin?)` + Swift `probeMLX` — pin 이 결과에
  포함되면 `[pin]` 만 broadcast, 아니면 전체 리스트 (기존 동작). Dashboard 는
  코드 수정 없이 자연스럽게 1 개만 노출.
- **Consumer 일원화**: timeline-summarizer (3 곳) / label-summarizer /
  APME judge / Swift TimelineSummarizer / Swift ApmeJudgeMlx 모두
  `resolveMlxModel(probeFirst)` 또는 `ApmeSettings.loadMlxConfig().model`
  우선 → 없으면 probe 첫 결과 → 최종 폴백 `Qwen3.6-35B-A3B-4bit`.
- **judgeModel DB 태그 정확화**: `effectiveJudgeModelTag(cfg)` — MLX backend
  일 때 pin 이 있으면 `mlx:<pin>`, 없으면 `mlx:<cfg.model>`. Non-MLX 백엔드는
  `cfg.model` 그대로.
- **TS probe 백오프**: `startMlxProbe` 에 exponential backoff (5→30s 캡)
  도입. Swift `probeMLX` 의 `mlxFailureCount`/`mlxNextInterval` 패턴 미러.
  `bug_local_llm_probe_no_backoff.md` 처방.

### 핵심 설계 결정
- **`llm.mlx` 를 `apme.judge` 에서 분리**: summarizer 는 APME 와 무관한데도
  같은 모델을 써야 하므로 settings 루트의 독립 블록. `apme.judge.model` 은
  backward compat 용으로 legacy fallback 만 유지 (placeholder `"qwen3-30b"`
  그대로 둠).
- **카탈로그를 UI 에서 숨기는 게 아니라 probe broadcast 자체를 `[pin]` 으로
  축소**: 이유 (a) 모든 dashboard (macOS/Android/iOS/TUI) 가 동일하게 반영,
  (b) usage-event/state-update payload 가 작아짐, (c) sibling bridge 의
  구버전 broadcast 와 focus relay override 가 이미 "daemon 캐시가 권위"
  모델이므로 추가 플럼빙 불필요.
- **Placeholder 판정을 엄격히**: `"qwen3-30b"` 는 실제 모델이 아니라
  기본값이어서 유저가 "이거 바꿔야지" 를 놓치기 쉬움 → 무조건 null 처리.
- **`nonisolated(unsafe)` 정적 캐시**: 30s TTL 은 race 가 나도 손해가 pure
  read-miss 한 번이므로 락 없음 선택. 기존 `ESP32Serial`, `AdbModule` 등과
  동일 패턴.
- **Settings UI picker 는 이번 범위 밖**: 현 단계는 `settings.json` 수동
  편집. Picker 를 붙이려면 "pin 적용 전 전체 리스트" 가 별도로 필요해서
  `unfilteredMlxModels` 같은 추가 state 필드가 생기는데 그 부분은 Phase 2.

검증: `pnpm test` 918/918 (신규 12 개 포함) + `xcodebuild -scheme
AgentDeck_macOS build` 성공. 런타임 활성화는 `~/.agentdeck/settings.json`
에 `"llm": { "mlx": { "model": "mlx-community/Qwen3.6-35B-A3B-4bit" } }`
추가 후 daemon 재시작.

플랜: `~/.claude/plans/mlx-atomic-minsky.md`. 커밋: `fbe1cdb9`.

### 후속 (`2b7b38b3`) — auto-pick default
`fbe1cdb9` 직후 사용자 확인: dashboard 에 여전히 두 개 표시. 원인 두 가지 —
(a) AgentDeck.app 이 세션 내내 구버전 바이너리로 돌고 있어 새 probe 필터가
반영되지 않음, (b) `settings.json` 에 `llm.mlx` pin 이 없는 상태였고 내 기본
동작이 "pin 없으면 전체 broadcast" 였음. (a) 는 사용자가 재기동으로 해결,
(b) 를 고치기 위해 **pin 이 없고 카탈로그가 >1 이면 첫 번째를 자동 선택**
하도록 기본값 변경 (TS `mlx-probe.ts` + Swift `probeMLX`). APME judge 의
기존 "first non-nanollava" auto-detect 와 동일 규칙이라 summarizer/judge
동작이 자동으로 수렴. 명시 pin 은 여전히 override. 카탈로그가 1 개뿐이면
그대로 — 회귀 없음.

**교훈**: "pin 없을 때 회귀 없음" 을 과하게 보수적으로 설계했음. 실제로는
기본값이 sensible auto-pick 이어야 사용자 입장에서 "고쳐진 느낌" 이 남.
새 기능의 default 는 항상 "비어 있는 설정으로도 합리적인 결과" 를 내야 함.

---

## 2026-04-18 — Terrarium NaN Crash Guard + Presence-Aware display_state + D200H Suppression

### 문제
1. Xcode 런타임 크래시: `WaterEffect.drawCausticLayer` at line 93
   (`let wave = sin(freq2 * t + time * 0.85 + linePhase) * amp`). 디스플레이
   sleep/wake, 창 최소화, 최초 레이아웃 등 Canvas 가 degenerate size(0 또는
   NaN/Inf) 를 전달할 때, `waveLen2 = w * 0.32 = 0` → `freq2 = 2π/0 = +Inf` →
   `sin(Inf*t) = NaN` → `CGPoint(NaN, NaN)` 가 Path.addLine(to:) 로 들어가면서
   CoreGraphics 가 트랩. 크래시 사이트로 표시된 line 93 은 NaN 이 *생성*되는
   지점일 뿐 실제 트랩은 그 직후.
2. `display_state` 이벤트가 **"모니터가 실제로 꺼짐"** 만 감지. 다음 상태에선
   Pixoo/ESP32/Android/D200H 가 계속 대시보드를 송출: 화면잠금(⌃⌘Q),
   스크린세이버 실행, Fast User Switching. 즉 자리를 뜨고 잠갔을 때도 기기가
   밝게 켜져 있어 외부 노출 + 전력 낭비.
3. D200H 는 아예 `display_state` 를 구독하지 않아 모니터가 꺼진 상황에서도
   15초마다 45~50KB set_buttons ZIP 을 계속 USB 로 푸시.
4. `LaunchSessionDialog.pickFolder()` 의 `NSOpenPanel.runModal()` 가
   `Unable to display open panel: your app is missing the User Selected File
   Read app sandbox entitlement` 으로 실패. `AgentDeck-Debug.entitlements`
   에는 키가 있지만 Debug/Release 둘 다 `AgentDeck.entitlements` 를 쓰고
   이쪽에 누락. `AgentDeck-Debug.entitlements` 는 프로젝트 참조만 있는 orphan.
5. 동일 다이얼로그의 Picker 가 `the selection "iterm" is invalid and does not
   have an associated tag` 경고. `@AppStorage("launch.lastTerminal") = "iterm"`
   복원 후 첫 렌더에 `installedTerminals = [.system]` 만 들어있어 태그 매칭 실패,
   `onAppear` 에서 채워지기 전에 Picker 가 이미 그려졌기 때문.

### 해결
**5aac12ba — Terrarium size guard** (`WaterEffect` + 6 sister files)
- 각 `draw(context:size:)` 진입 직후:
  ```swift
  guard size.width > 0, size.height > 0,
        size.width.isFinite, size.height.isFinite else { return }
  ```
- degenerate size 에선 한 프레임 스킵이 정답. 이후 모든 수식이 유한치.
- 적용 파일: `WaterEffect`, `KelpField`, `LightRaySystem`, `PlanktonSystem`,
  `RockFormation`, `SandDisturbance`, `WaterSurface`.

**27d2494e — DisplayMonitor 확장 + D200H 구독**
- `DisplayMonitor` actor 에 네 개 입력을 합성해 단일 `displayOn` 방송.
  ```
  displayOn = !isDisplayAsleep       (CGDisplayIsAsleep, 2s 폴링 — 기존)
           && !isScreenLocked        (com.apple.screenIsLocked / Unlocked)
           && !isScreensaverActive   (com.apple.screensaver.didstart / didstop)
           && !isSessionInactive     (NSWorkspace.sessionDid{Resign,Become}Active)
  ```
- `DistributedNotificationCenter` 과 `NSWorkspace.shared.notificationCenter`
  만 사용 — **전부 public API**. 추가 entitlement 없음, App Store 리뷰 이슈 없음.
  CGS 프라이빗 함수(`CGSSessionScreenIsLocked` 등) 회피.
- 기존 `onStateChanged` 콜백 API 그대로 → DaemonServer 변경 불필요 (해당
  레이어는 hardware-only 시절의 `display_state` 브로드캐스트만 그대로 씀).
- `D200hHidModule.handleBroadcast` 에 `case "display_state"` 추가:
  - off: `CMD_SET_BRIGHTNESS 0` 1회 전송 + `displaySuppressed=true`. 이후
    `updateDisplay()` 는 dim flag 에서 조기 return → 15s heartbeat 에서
    호출돼도 USB 쓰기 안 함.
  - on: `CMD_SET_BRIGHTNESS 100` + `lastStateHash=""`, `lastFullSlots=[]`
    리셋 후 full refresh.
  - `initializeDevice()` 는 현재 `displaySuppressed` 를 체크해 재연결 시에도
    dim 상태를 유지. 즉 호스트가 잠겨 있는 동안 D200H 를 탈착 후 재부착해도
    깜빡 대시보드 flash 가 없음.

**38a15e7a — 샌드박스 폴더 피커 + Picker 초기 태그**
- `AgentDeck.entitlements` 에 `com.apple.security.files.user-selected.read-only`
  추가. NSOpenPanel 이 열리려면 이 키가 유일한 요건. write 필요 없음
  (선택 경로는 string 으로만 보관되고 자식 프로세스가 상속).
- `LaunchSessionDialog.swift:13`: `installedTerminals` 초기값을
  `[.system]` → `TerminalApp.installed()` 로. @State 이니셜라이저에서 한 번
  평가 → 첫 렌더부터 모든 설치 터미널 태그 존재.

### 핵심 설계 결정
- **`display_state` 의미 확장**: "모니터 전원" → "사용자 존재 AND 모니터 전원".
  이벤트 이름과 downstream 소비자 계약은 그대로 유지 (Pixoo/ESP32/Android 가
  이미 신뢰하는 경로). 합성 책임을 `DisplayMonitor` actor 내부로 격리해서,
  새 입력(예: 노트북 lid-closed 상태) 추가 시 한 곳만 바꾸면 됨.
- **D200H suppression 전략**: 디바이스 펌웨어는 30초 후 기본 시계 화면으로
  복귀하지만, brightness 0 이면 시각적으로 동일. 그래서 heartbeat 중단이
  안전 — 깨어날 때 `lastStateHash=""` 로 강제 재송출해 복구.
- **App Store 호환성 점검**: 이번에 사용한 모든 입력(`com.apple.screenIsLocked`,
  `com.apple.screensaver.didstart`, `NSWorkspace.sessionDid{Resign,Become}Active`)
  은 DistributedNotificationCenter / AppKit public 경로로, 샌드박스 내에서
  권한 추가 없이 동작. 리뷰 노트에 "detect user presence to dim connected
  accessories when the Mac is unattended" 한 줄만 준비하면 충분.
- **엔티틀먼트 최소주의**: 폴더 피커에 `user-selected.read-only` 로 충분할 때
  `read-write` 를 넣지 않음. 앱이 실제로 선택 경로에 쓰기 시작하면 그때 업그레이드.

---

## 2026-04-18 — Gateway Protocol Single-Source (Follow-up: Activation)

### 문제
직전 같은 날짜 세션(커밋 990d3b52, e1cea599)에서 Gateway 와이어 포맷을
`shared/src/gateway-protocol.ts` 로 single-source 하고 CI 드리프트 게이트와
parity fixtures 를 추가했지만, 네 가지가 미완으로 남아 있었다.
1. `generated/protocol/` baseline 이 아직 커밋되지 않아 CI "Protocol drift
   check" 스텝이 비교 대상 없이 실질적 무효.
2. Swift 쪽 parity test (`apple/AgentDeckTests/GatewayParityTests.swift`) 부재.
3. Swift `OpenClawAdapter.swift` 가 여전히 로컬 `[String: Any]` 파싱만 하고
   생성 타입(`generated/protocol/GatewayFrame.swift`) 을 실제로 쓰지 않음.
4. Node `rpcCall(method: string, params: Record<string, unknown>)` 시그니처가
   method/params 상관관계를 강제하지 않아 `events.history` 같은 spec 외 호출이
   통과됨.

### 해결
**1457f32d — Baseline `generated/protocol/` 커밋** (11 files, +7361)
- `pnpm build && bash scripts/generate-protocol.sh` 후 `git add generated/protocol`.
- 이제 PR 에서 `shared/src/*` 를 변경하고 regen 을 잊으면 CI 가 red.

**b1dbb3c7 — Swift parity tests + OpenClawAdapter 전환**
- `apple/AgentDeckTests/GatewayParityTests.swift` 신설 — Node
  `gateway-parity-fixtures.test.ts` 를 1:1 미러. `#filePath` 로 repo-root
  경유해 `tests/parity/gateway-frames/*.json` 접근. `xcodebuild test-only` 로
  6 tests pass in 0.008s.
- `apple/project.yml` + pbxproj: `../generated/protocol/GatewayFrame.swift`
  를 `AgentDeck_macOS` 타겟 sources 에 추가 (iOS 는 OpenClaw adapter 미탑재).
- `OpenClawAdapter.swift` `handleMessage(_:)`: `JSONDecoder().decode(ADGatewayFrame.self, ...)`
  로 envelope 디코드 → `ADType` (`.req/.res/.event`) + `ADGatewayEventName`
  (`.connectChallenge/.chat/.execApprovalRequested/...`) enum 으로 switch.
  Exhaustive — shared spec 에 event 추가 시 여기서 컴파일 실패.
- Payload field access 는 여전히 `[String: Any]`. quicktype 의 `ADGateway`
  payload struct 가 모든 이벤트 필드를 flat optional 로 합쳐 놓아 typed 접근이
  이득이 없다. 실제 parity 강제 포인트는 discriminator 레벨이면 충분.
- `scripts/generate-protocol.sh`:
  - GatewayFrame 쪽 `--protocol equatable` 제거 — `ADGatewayError.details`,
    `ADChatToolInvocation.input/output` 이 `JSONAny?` 이라 Equatable synthesis
    실패 → Swift 6 strict mode 가 전체 타겟을 reject.
  - `sed -i '' -e 's/^class JSONCodingKey:/final class JSONCodingKey:/'` 후처리 —
    Swift 6 는 non-final Sendable class 거부.
- 병행 작업으로 working tree 에 남아 있던 두 변경을 함께 커밋:
  `loadDeviceIdentity()` degradation 분기 (sandbox/not-paired/other), 모델
  카탈로그 `missing` 필드 + `defaultModel` by-name fallback.

**31f2d4fe — `GatewayMethodMap` + `rpcCall` narrowing + dead code 제거**
- `shared/src/gateway-protocol.ts` 에 `GatewayMethodMap` interface 추가 —
  method name → `{ params, result }` 매핑.
- `OpenClawAdapter.rpcCall` 를
  `<M extends keyof GatewayMethodMap>(method: M, params: Map[M]['params']) => Promise<Map[M]['result']>`
  로 generic 화. method/params 불일치, spec 외 method 모두 컴파일 에러.
- `fetchHistory()` + `events.history` 참조 삭제. 메소드 정의 외 caller 가
  bridge/plugin/setup 어디에도 없던 dead code. 재등장 시 shared spec 에
  정식 편입하는 쪽으로 정책 고정.
- **`ConnectParams` wire 정합성 정정**: spec 은 `{ auth, requestScopes, clientInfo }`
  였지만 `sendConnectRequest` 가 실제 보내는 바이트는
  `{ minProtocol, maxProtocol, client, role, scopes, caps, device?, auth? }` —
  완전히 다른 shape. spec 을 wire 에 맞춤. `connect` RPC 가 `rpcCall` 을 안
  거치는 별도 경로라 기존엔 spec 거짓말이 걸러지지 않았다.
- Regen 후 `generated/protocol/GatewayFrame.{swift,kt,json}` 갱신. 드리프트
  게이트가 "DIRTY" 를 즉시 잡아낸 덕에 regen 누락 방지 확인.
- 검증: `pnpm build` 통과, `pnpm vitest run` 906/906 pass, parity 17/17 pass,
  `xcodebuild AgentDeck_macOS build` BUILD SUCCEEDED.

### 핵심 설계 결정
- **Discriminator-level parity 면 충분**: Swift 어댑터가 envelope 과 이벤트
  이름은 typed enum 으로 받고 payload 필드는 dict 로 읽는 hybrid 방식 채택.
  quicktype 의 flat 합집합 struct 로 field-level typing 을 강제해도 `JSONAny`
  때문에 실질적 안전성은 늘지 않는다. Spec drift 가 생기면 envelope/이벤트
  rename 이 먼저 잡히므로 실무상 충분.
- **`events.history` 는 remove-not-spec**: 해당 메소드가 실제로 쓰이는 시점에
  shared 에 편입하고 regen. 지금 미래 추측으로 spec 에 넣어두면 drift 표면만
  늘어난다.
- **Swift 6 + quicktype 호환 패치는 generator 에 박는다**: `--protocol equatable`
  제거와 `JSONCodingKey` final 변환을 매번 손으로 하지 않도록
  `generate-protocol.sh` 안에 고정. 차후 spec 이 바뀌어 재생성해도 동일 결과.
- **`ConnectParams` 처럼 wire 와 spec 이 어긋난 부분은 wire 쪽이 ground truth**:
  단일 소스의 가치는 spec 이 구현과 합의될 때만 성립. 이번에 즉시 정정.

### 후속
- `generated/protocol/` 크기가 커서(11 files, 7361 lines) PR diff 에 소음을
  일으킬 수 있음 — `.github/workflows/` 에 `generated/protocol/**` 패스트링 필터가
  필요한지 관찰 후 결정.
- Swift 쪽 `BridgeEvent.swift` / `PluginCommand.swift` 도 같은 방식으로
  target 편입 가능 (지금은 `AgentCommand.swift` 만 포함). 현재 native
  hand-crafted `Protocol.swift` 와 중복될 수 있어 우선순위는 낮음.
- APME `events.history` 가 필요해지면 shared spec 에 정식 추가 + regen +
  Node/Swift 양쪽 handler 구현.

---

## 2026-04-18 — Gateway Protocol Single-Source & Drift Gate

### 문제
OpenClaw Gateway 와이어 포맷(프레임 envelope, Ed25519 핸드셰이크, RPC 메소드, 이벤트 카탈로그)이 세 곳에 **독립 선언**되어 있었다:
- `bridge/src/adapters/openclaw.ts` — Node 어댑터 내부 `interface GatewayRequest / GatewayResponse / GatewayEventFrame / GatewaySession`
- `apple/AgentDeck/Daemon/Gateway/OpenClawAdapter.swift` — Swift 포팅
- `docs/` — 문서화되지 않음 (openclaw.ts 주석 43~67줄만 존재)

새 필드/메소드를 추가할 때 Node/Swift 둘 다 수동으로 맞춰야 했고, parity 검증 수단이 없어 silent drift 위험. App Store 제출을 앞둔 구조 점검의 일환으로 single-source 로 수렴.

### 해결
1. **Shared single source 생성** — `shared/src/gateway-protocol.ts` 신규. `GatewayFrame`, 5개 메소드(`connect` / `chat.send` / `chat.abort` / `exec.approval.resolve` / `sessions.list`), 7개 이벤트(`connect.challenge` / `chat` / `exec.approval.*` / `presence` / `tick` / `shutdown`)의 타입·payload 스키마를 선언. `ED25519_SPKI_PREFIX_LEN`, `GATEWAY_PROTOCOL_VERSION`, `GATEWAY_DEFAULT_PORT` 상수도 이동.
2. **코드젠 확장** — `scripts/generate-protocol.sh` 에 GatewayFrame Swift/Kotlin 생성 스텝 3개 추가. `generated/protocol/GatewayFrame.{swift,kt,json}` 산출. `.gitignore` 의 `generated/` 를 `generated/*` + `!generated/protocol/` 로 바꿔 드리프트 게이트를 track 가능.
3. **CI 드리프트 게이트** — `.github/workflows/ci.yml` 에 "Protocol drift check" 스텝 추가. PR 에서 `pnpm generate-protocol` 실행 후 `git diff --quiet generated/protocol/` 위반 시 실패. 한 번 baseline 이 커밋되면 TS 쪽 Gateway 타입을 바꾸면서 generated/ 를 같이 커밋 안 하면 CI 실패.
4. **Node 어댑터 수렴** — `bridge/src/adapters/openclaw.ts` 에서 로컬 interface 5개 제거, `@agentdeck/shared` import 로 전환. `bridge/src/types.ts` 에 Gateway 타입 re-export 추가. `rpcCall` 의 message 객체는 `as const` 리터럴로 유니온 좁히기 회피 (loose 유지 — 다음 단계에서 method/params union 으로 엄격화 예정).
5. **스펙 문서** — `docs/gateway-protocol.md` 신규. 프레임 포맷 다이어그램, Ed25519 서명 payload 조립(`v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce`), 메소드·이벤트 표, 재연결/버전 규칙.
6. **Parity fixtures 1차** — `tests/parity/gateway-frames/*.json` 7종(connect-challenge/ok, chat-delta/final, exec-approval-requested, rpc-error, tick) + README. `bridge/src/__tests__/gateway-parity-fixtures.test.ts` 17건으로 frame discriminator 와 shape 계약 검증. Swift 쪽 `GatewayParityTests.swift` 추가는 별도 세션.
7. **mDNS 복구 단축** — `bridge/src/mdns.ts` `MDNS_RECOVERY_INTERVAL` 30s → 5s. WiFi 전환/wake 복귀 시 클라이언트 discovery 지연 창을 6배 축소.
8. **Swift 어댑터 degradation UX** — `OpenClawAdapter.swift` `loadDeviceIdentity()` catch 블록을 샌드박스/not-paired/기타 에러로 분기해 info 레벨로 구체적 안내 로그. SettingsScreen 의 sandboxed 배너와 쌍을 이룸. (이 파일은 본 세션 커밋에서 제외 — 별개 OpenClaw model catalog 수정이 섞여 있어 사용자 마무리 후 별도 커밋 예정.)

### 핵심 설계 결정
- **점진적 수렴, 전면 통합 아님**: Node daemon 과 Swift daemon 은 각자 유지. 프로토콜 타입만 shared 로 single-source, 구현체(런타임/전송)는 플랫폼별 네이티브 최적화. 이유: macOS 는 App Store 샌드박스 준수 위해 Swift in-process daemon 필수, Linux/dev 는 Node CLI 필수 — 두 런타임을 합치면 배포 제약 충돌.
- **generated/protocol/ 를 track**: CI drift gate 는 track 된 baseline 이 있어야 비교 대상이 생김. `generated/` 전체를 ignore 했던 정책을 protocol 하위만 예외로 완화. baseline 첫 커밋(`bash scripts/generate-protocol.sh && git add generated/protocol && git commit`) 은 다음 세션에서 수행.
- **rpcCall 엄격화는 다음 단계**: 지금은 method 가 `string`, params 가 `Record<string, unknown>` 로 loose. GatewayMethodName/GatewayMethodParams union 으로 좁히면 `events.history` 같은 스펙 외 호출이 걸리는데, 그 메소드의 shared 선언 추가 또는 deprecation 여부 판단이 선행. 엄격화는 다음 PR.
- **Apple Swift OpenClawAdapter 는 이 커밋에서 제외**: 사용자가 model catalog 파싱(`missing` 필드, default key→name) 을 별도로 수정 중. 내 degradation 로그 diff 와 섞지 않고 분리 커밋 예정.

### 후속
- `generated/protocol/` baseline 커밋 → CI drift gate 활성화
- Swift `apple/AgentDeckTests/GatewayParityTests.swift` — 동일 fixture JSON 을 `JSONDecoder` 로 decode 해 Node 와 동일 invariants 검증
- Swift `OpenClawAdapter.swift` 를 `generated/protocol/GatewayFrame.swift` 로 전환해 양쪽 단일 소스 완성
- `rpcCall` 타입 엄격화 + `events.history` 스펙 편입 또는 제거
- Phase 3 (Device Transport 추상화) 별도 플랜

---

## 2026-04-18 — Phase 0 잔여 + Phase 2 온보딩 폴리시

### 목표
직전 커밋 5bf80da3 에서 해소한 Track A/B 위에, 감사 리포트의 남은 Phase 0 항목과 Phase 2 온보딩 정리를 일괄 처리.

### Phase 0 잔여

**APME Layer 1 graceful disable (ApmeRunner)**
- `ApmeRunner.isLayer1Available` (현재 상시 false) + `layer1SkippedReason` (`"sandbox"` | `"not_implemented"`) 도입. Layer 1 비활성 이유를 결과 payload 에 붙여 대시보드/설정 UI 가 "LLM-only evaluation" 배지를 렌더할 수 있게.
- Phase 2+ 에서 LibGit2 + Xcode results-bundle reader 로 Swift 포팅 전까지 "not_implemented" 반환.

**App Review Notes (`apple/APP_REVIEW_NOTES.md`)**
- 제출용 500-word-이내 문서. network.server 정당화 (iOS Dashboard 앱 접속), Bonjour, Group Container, HookInstaller opt-in, USB HID, subprocess 제거, APME FoundationModels 기본, 리뷰어 데모 플로우.

**LSRequiresIPhoneOS auto-strip 검증**
- `xcodebuild -configuration Release` archive 후 `.app/Contents/Info.plist` 확인. `LSRequiresIPhoneOS` 키 **부재** 확인 — Xcode 가 macOS 타겟에서 자동 strip (xcode-infoplist-auto-strip.md 메모 재확인).
- 잔여 iOS-only 키: `UILaunchScreen`, `UISupportedInterfaceOrientations`, `UISupportedInterfaceOrientations~ipad`. App Review 에서 rejection 유발 안 함 (harmless cruft). 소스 `Info.plist` 정리는 별건 TODO.

**External-home 샌드박스 UI hints (SettingsScreen)**
- `AgentDeckRuntime.isSandboxed` (AgentDeckPaths 내) 추가 — `APP_SANDBOX_CONTAINER_ID` env 존재 검사.
- Services 섹션에 3개 status row 신설:
  - `codexAuthRow` — "Codex web auth status unavailable in App Store build. Use `codex login` from CLI."
  - `openClawIntegrationRow` — "OpenClaw gateway unavailable in App Store build. Install via CLI: `npm i -g @openclaw/cli`."
  - `adbIntegrationRow` — "Android/ADB device integration requires a separately installed `adb` binary; unavailable in App Store build."
- 비-샌드박스 빌드에선 각각 "configured via CLI" / "install via brew" 안내로 대체.

### Phase 2 온보딩 폴리시

**Notification permission flow**
- 신설 `apple/AgentDeck/App/NotificationPermission.swift` — `@MainActor static func requestIfNeeded() async`. 첫 실행 시 NSAlert("Enable AgentDeck notifications?") → 사용자 선택 → UNUserNotificationCenter.requestAuthorization. `AppPreferences.hasRequestedNotifications` 으로 idempotent.
- xctest 환경(XCTestConfigurationFilePath/BundlePath/SessionIdentifier) 에선 early-return — NSAlert 가 xctest host 를 멈추지 않도록 (SingletonGuard 패턴 미러).
- `AgentDeckApp.configureDaemonConnection()` 에 1.5s 지연 Task 로 연결.

**MonitorScreen empty-state 가이드 (MonitorScreen)**
- `MonitorEmptyGuideOverlay` 추가 — bridgeConnected 이고 siblingSessions 이 비어있고 `hasSeenMonitorEmptyGuide == false` 일 때만 표시.
- "Start your first session." 헤드라인 + Launch Session / Preview Devices 버튼 + Got it 디스미스.
- `AppPreferences.hasSeenMonitorEmptyGuide` @Published 추가.

**Menubar "Reports" 버튼 (ControlTowerPanel)**
- 기존 "APME" 라벨을 "Reports" 로 교체 (chart.bar.fill 아이콘 유지). 일반 사용자에게 APME 용어가 낯설어 접근성 개선.
- Dashboard / Reports / Preview Devices 순서로 액션 바 정렬.

**About 탭 브랜드 카피 (SettingsScreen)**
- "Stop Chatting. Start Steering." 타이틀 + "AgentDeck gives you real-time monitoring and evaluation for Claude Code, Codex, OpenCode, and OpenClaw sessions…" 본문. 기존 App/Version/Bundle rows 유지.

### 보류 / Post-launch
- **Settings Advanced/Hardware DisclosureGroup** — 스킵. D200H/ESP32 섹션이 하드웨어 없는 사용자에게 dead weight 지만 기존 "Tank Status Sections" 토글로 이미 숨길 수 있음. 별도 DisclosureGroup 은 post-launch 개선.
- Swift Layer 1 실제 포팅 (LibGit2 + results-bundle).
- APME 대시보드 HTML 측에서 `layer1SkippedReason` 렌더 (서버 side 별건).

### 검증
- `xcodebuild AgentDeck_macOS Debug build` → BUILD SUCCEEDED.
- `xcodebuild AgentDeck_macOS Release build` → BUILD SUCCEEDED.
- `xcodebuild AgentDeck_iOS Debug build` → BUILD SUCCEEDED.
- **`xcodebuild test` hang 발생** — 이전 세션의 Xcode debug AgentDeck.app zombie (PID 12668, SX state) 가 사용자 세션에 살아있어 xctest 의 새 AgentDeck host 인스턴스가 test-runner 연결을 수립하지 못함. 코드 회귀 아님 (직전 5bf80da3 커밋에선 57/57 passed). 유저 재부팅 후 재실행 시 해결 예상. 메모리에 기록.

### 파일 변경
- Modified: `apple/AgentDeck.xcodeproj/project.pbxproj` (xcodegen regen), `apple/AgentDeck/App/{AgentDeckApp,AgentDeckPaths,AppPreferences}.swift`, `apple/AgentDeck/Daemon/Apme/ApmeRunner.swift`, `apple/AgentDeck/UI/{MenuBar/ControlTowerPanel,Monitor/MonitorScreen,Settings/SettingsScreen}.swift`.
- Created: `apple/APP_REVIEW_NOTES.md`, `apple/AgentDeck/App/NotificationPermission.swift`.

---

## 2026-04-17 — App Store blocker 3종 해소 + Device Preview 네이티브 14기기

### 배경
오전 감사 리포트(`~/.claude/plans/agentdeck-parallel-muffin.md`)에서 App Store 심사 차단 5건 중 3건(#7 Group Container, #8 subprocess spawn, #9 HookInstaller 자동 설치)과 하드웨어 無 사용자 온보딩 공백을 해소하기 위한 Device Preview(3/5/6) 트랙을 병렬 실행.

### Track A — App Store blocker 해소

**#7 데이터를 App Group Container 로 이전 (완료)**
- 신규 `apple/AgentDeck/App/AgentDeckPaths.swift` — 단일 진입점. `containerURL(forSecurityApplicationGroupIdentifier: "group.bound.serendipity.agentdeck.dashboard")` → 서명 빌드에선 Group Container, 미서명(dev/xctest)에선 `~/.agentdeck/` fallback. `migrateLegacyDataIfNeeded()` 가 legacy `~/.agentdeck/` (getpwuid real-home)에서 Group Container 로 파일 단위 복사(덮어쓰지 않음).
- `AgentDeck.entitlements` — `temporary-exception.files.home-relative-path.read-write` 4개 경로 제거, `application-groups = ["group.bound.serendipity.agentdeck.dashboard"]` 추가. `project.yml` 도 동일 변경 + `xcodegen generate` 로 pbxproj 재생성.
- 경로 사용처 전수 변경: `AuthManager`, `Logger`, `LocalSessionDiscovery`, `BridgeDiscovery`, `SingletonGuard` (atexit/signal handler — `unlink` 그대로), `DaemonService` (SIGTERM/SIGINT 정리), `ApmeSettings`, `AppPreferences.writeApmeJudgeBackendToSettingsJson`. `ApmeStore` 는 `AuthManager.agentDeckDir` 경유라 자동 추종.
- `AgentDeckApp.init()` 에 `AgentDeckPaths.migrateLegacyDataIfNeeded()` 추가. best-effort — per-file 실패는 `NSLog` 만, 스타트업 블로킹 없음.
- `OpenClawAdapter` (`~/.openclaw/identity/*`), `UsageAPIClient` (`~/.codex/auth.json`), `AdbModule`/`BridgeLogStream` (외부 바이너리 탐색 fallback) 은 App Sandbox 에서 EPERM → 기존 graceful fallback 이 nil 반환. v1 출시 한계(External CLI integration disabled in App Store build)로 기록 — 릴리즈 노트 과제.

**#8 SessionLauncher Process() 스폰 제거 (완료, 에이전트 adcbc38b)**
- `shell("which", ...)` 헬퍼 + findInstalledBridge/findClaude 의 fallback 3곳 삭제. Hardcoded candidate path 리스트만 사용 — 실패 시 nil → `showClaudeInstallPrompt()` 기존 경로. `shellEscape` 는 AppleScript/`.command` 스크립트 경로 escaping 용으로 유지.
- 부수 수정: `resolveLaunchPlan` 의 claudeCode → plainClaude last-resort fallback 에 `daemonPrefix` 누락 → 기존 `SessionLauncherTests.testFallsBackToPlainClaudeAndPreservesProjectPath` 가 pre-existing FAIL 상태. 테스트 의도(HookInstaller 가 설치한 hook 이 여전히 daemon 에 도달해야 함)대로 코드 측 수정. 57 테스트 전부 passed.

**#9 HookInstaller opt-in 다이얼로그 (완료, 에이전트 a64bc0d3)**
- `AppPreferences` 에 `HookInstallConsent` enum (`unknown` / `accepted` / `declined`), `@Published hookInstallConsent`, `hooksInstalled`, `claudeSettingsBookmark`/`claudeSettingsPath` 추가. `storeClaudeSettingsBookmark` / `resolveClaudeSettingsURL` / `clearClaudeSettingsAccess` helper (security-scoped bookmark, Antigravity 패턴 미러).
- `HookInstaller.installIfNeeded()` 는 consent != `.accepted` 또는 bookmark 없으면 no-op + 로그. `promptAndInstall()` — NSAlert("무엇이 설치되는지" 안내) → NSOpenPanel(기본 `~/.claude/settings.local.json` 선택) → bookmark 저장 후 실제 쓰기. `uninstallAndRevoke()` = 기존 `uninstall()` + bookmark revoke + `.declined`.
- Settings 화면에 **Claude Code Hooks** GroupBox 추가: 상태 LED, 경로 표시, "Enable Claude Code Hooks…" + "Remove" 버튼. macOS 레이아웃 전체를 `ScrollView` 로 감싸 신설 섹션 스크롤 가능.
- 기존 `DaemonServer.startServices step11` 의 `HookInstaller.installIfNeeded()` 호출은 유지 — 내부에서 consent 체크로 조용히 no-op.

**#13 iOS 스킴 복구 (완료)**
- `SettingsScreen.swift:618-633` 의 `ApmeJudgeFoundationModels` / `ApmeJudgeApi` 참조를 `#if os(macOS)` 로 감쌌고, `.pickerStyle(.radioGroup)` 은 iOS 용 `.inline` 대체 추가. iOS 빌드 복구.

### Track B — Device Preview 네이티브 14기기 (완료)

**#3 PixooRenderer preview adapter (에이전트 a4e63378)**
- `apple/AgentDeck/Rendering/PixooPreview.swift` (~215 LOC, cross-platform).
- 재포팅 없음 — 기존 `PixooRenderer.render(dashboardState:)` (1535 LOC) 그대로 재사용. Preview config(agent/state/sessionCount/fiveHourPercent/gatewayAvailable) → `DashboardState` 합성 → 64×64 RGB24 Data → `CGImage` (nearest-neighbor) → SwiftUI `Image`. Fresh renderer per call 로 애니메이션 상태 결정론적.
- iOS fallback: 64×64 solid #20293A 채움.

**#5 TUITerrariumRenderer SwiftUI 포팅 (에이전트 aa33af04)**
- `apple/AgentDeck/Rendering/TUITerrariumRenderer.swift` (~340 LOC, cross-platform). `TerrariumPreviewConfig(agents, states, animationFrame, width=60, height=20)` → `TUITerrariumRenderer` View. 이름 충돌(`apple/AgentDeck/Terrarium/TerrariumRenderer.swift` 기존) 회피 위해 prefix `TUI`.
- 의도적 단순화: Braille 14×5 sprite → 3-char ASCII glyph (octopus `(o)`, cloud `<o>`, opencode `┈▢┈`, crayfish `λoλ`), Boids/Lissajous/random bubble → deterministic sin-based. 상태 팔레트 4 bucket (`idle/processing/awaiting/disconnected`), state→depth 매핑(0.30/0.50/0.82) 보존.

**#6 DevicePreviewScreen + 14 기기 + 메뉴바 진입 (에이전트 a42243b1)**
- `apple/AgentDeck/UI/Preview/` 신설. `DevicePreviewCatalog.swift` (115 LOC, `PreviewDevice` 15→14), `DevicePreviewScreen.swift` (198 LOC, `NavigationSplitView` macOS / 평면 iOS, Agent/State/Sessions picker, `TimelineView` 기반 `animationFrame`), `Devices/{DevicePreviewShared,DeskPreviews,WearableTabletPreviews,EinkEsp32Previews,MatrixTerminalPreviews}.swift` (총 ~840 LOC).
- 15→14 감량: Apple Watch 46mm/42mm 병합 (4mm 차이 시각 구분 불가).
- 최종 14: Desk 3(StreamDeck+, D200H key, D200H deck), Wearable 1, Tablet 2(iPad, Android), E-ink 2(mono/color), ESP32 4(86Box/IPS3.5L/IPS3.5P/Round), Matrix 1(Ulanzi), Terminal 1(Terrarium).
- `AgentDeckApp.swift` 에 `Window("Device Preview", id: "device-preview")` scene (macOS, 1100×760). `ControlTowerPanel.swift` 에 "Preview Devices" 버튼 + 빈 상태 카피 "No devices connected" → "Devices are optional — your agents work standalone. [View what devices add →]".
- `AppPreferences.hasSeenDevicePreview` @Published 추가.

### 검증
- `xcodebuild -scheme AgentDeck_macOS test -only-testing:AgentDeckTests_macOS` → **57 tests passed, TEST SUCCEEDED**. (Pre-existing `testFallsBackToPlainClaudeAndPreservesProjectPath` FAIL 을 SessionLauncher.swift 1줄 수정으로 복구 — 감지는 이번 감사 덕분.)
- `xcodebuild -scheme AgentDeck_macOS build` → BUILD SUCCEEDED.
- `xcodebuild -scheme AgentDeck_iOS -destination 'generic/platform=iOS' build` → BUILD SUCCEEDED.
- 런타임 확인은 App Store Connect 서명 후 다음 TestFlight 빌드에서 수행 예정(현 로컬 dev 빌드는 서명 X → Group Container API 가 nil → fallback 경로로 기존 동작 유지).

### 남은 블로커 (Phase 0 마무리)
- **#4 App Review Notes 작성**: "왜 로컬 서버(9120+)가 필요한가 — iOS/iPadOS Dashboard 앱이 동일 네트워크에서 접속" — 제출 직전 작성.
- **#B5 macOS 타겟 `LSRequiresIPhoneOS` 제거**: `Info.plist:25`. Xcode 가 macOS 타겟에서 자동 strip 한다는 기존 메모(`xcode-infoplist-auto-strip.md`) 확인 필요.
- **OpenClawAdapter / UsageAPIClient / AdbModule 외부 경로 접근**: sandbox 에서 graceful fallback 동작 확인(crash 없음)은 되어있으나, 각각 "feature unavailable in App Store build" UI 안내 추가 권장.
- **Onboarding Phase 2**: About 탭 브랜드 카피, D200H/ESP32 섹션 "Advanced" 그룹핑, APME 리포트 자동 노출 — 별도 트랙.

### Commit
- 본 작업 단일 커밋 권장 (Track A + Track B 병렬 검증 후 atomic). 파일 대상: `apple/AgentDeck.xcodeproj/project.pbxproj`, `apple/project.yml`, `apple/AgentDeck/App/*.swift`, `apple/AgentDeck/Daemon/{Core,Server,Apme,DaemonService}.swift` 일부, `apple/AgentDeck/Net/*.swift`, `apple/AgentDeck/Rendering/{PixooPreview,TUITerrariumRenderer}.swift`, `apple/AgentDeck/UI/{MenuBar,Settings,Preview/**}.swift`, `apple/AgentDeck/Resources/AgentDeck.entitlements`, `DEVELOPMENT_LOG.md`.

---

## 2026-04-17 — Wake 먹통 근본 원인 수정 (D200H IOHIDManager off-main)

### 문제
오전에 올린 2026-04-17 grace-window fix 는 증상만 덮은 임시 방어선이었다 ("한계 / 추후 과제" 에 명시됨). 실제 원인: `D200hHidModule.start()` 가 `IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), ...)` 로 매치/제거/입력리포트 콜백 전부를 **메인 런루프**에 스케줄. `handleWake()` 5초 sleep 뒤 `start()` 가 새 매니저를 만들고 `IOHIDManagerOpen` + `IOHIDManagerCopyDevices` 를 동기 호출, 이어서 match 콜백이 메인에서 `handleDeviceAttached` → `IOHIDDeviceOpen` 까지 실행. 이 전 과정이 Dashboard WS 파이프라인이 쓰는 메인 액터를 점유해 `[Lifecycle] bridge data stale` watchdog 이 터짐.

### 해결

**1차 시도 — DispatchQueue 전환 (실패, 크래시)**
- `IOHIDManagerSetDispatchQueue(manager, hidQueue)` + `IOHIDManagerUnscheduleFromRunLoop` 제거 + `IOHIDManagerClose` 유지.
- 런타임 결과: wake 복구 중 `stop()` 에서 `assertion failure: Invalid dispatch state: 0x0` 크래시.
- 원인: dispatch-queue 스케줄된 IOHIDManager 는 `Activate` / `Cancel` / `SetCancelHandler` (비동기 cancel 핸들러) 라이프사이클을 요구. `Open` / `Close` 는 런루프 전용 경로. API 믹스 불가.

**2차 시도 — 백그라운드 런루프 스레드 (성공)**
- 신설 `HIDRunLoopThread` 싱글톤: 전용 `Thread` + `NSRunLoop` + `NSMachPort`(런루프 유지용) + `CFRunLoopGetCurrent()` 캡처.
- `IOHIDManagerScheduleWithRunLoop(manager, HIDRunLoopThread.shared.runLoop, CFRunLoopMode.defaultMode.rawValue)` — 호출 시그니처 동일, 타겟만 main → background.
- `Open`/`Close` 라이프사이클 그대로 유지 → 크래시 없음.
- 검증 로그: `D200H wake recovery — full teardown + restart` → `D200H reconnected after wake` ~5s, `[Lifecycle] bridge data stale` **미발생**, Dashboard Usage/Sessions 업데이트 정상.

**Grace window 축소**
- `AgentStateHolder.wakeGracePeriodSec` 10 → 3초. D200H IOHID 재시작이 메인 스레드를 더 이상 점유하지 않으므로 남은 ESP32 (2s 시리얼 재오픈) + mDNS republish 에 필요한 최소치만 유지.

### 핵심 설계 결정

1. **Dispatch queue vs 백그라운드 런루프** — IOHIDManager 는 dispatch-queue 모드에서 별도 라이프사이클 API(`Activate`/`Cancel` + async cancel handler)를 요구하는데, 이 전환은 `handleDeviceAttached` 내 동기 `IOHIDDeviceOpen` 경로까지 재설계해야 함. 백그라운드 런루프 방식은 기존 코드 변경이 3줄(`ScheduleWithRunLoop` 인자만 교체) + 신규 헬퍼 1개로 끝남.
2. **NSMachPort 가 런루프 keep-alive 용** — `RunLoop.run()` 은 스케줄된 source 가 없으면 즉시 반환하므로, 영구 `NSMachPort` 를 하나 붙여 런루프가 살아있게 유지. `CFRunLoopSource` + `CFRunLoopSourceContext` 로 구현할 수도 있으나 Objective-C 런타임 의존성만 추가됨.
3. **ESP32Serial 은 동일 수정 불필요** — 이미 `actor` 로 선언되어 협력적 executor 에서 돌고, `Task.sleep` 은 비차단. Darwin `read/write/close` syscall 은 actor 의 백그라운드 협력 쓰레드에서 실행되어 메인을 막지 않음.
4. **콜백 thread-safety** — 매치/제거/입력리포트 세 콜백이 동일 런루프에서 **직렬화**되어 실행되므로 `nonisolated(unsafe)` 상태 접근 패턴은 유지됨 (이전 메인 런루프 직렬화와 동치).
5. **Usage 0% 는 별건** — `[UsageAPI] raw:` 덤프는 `AGENTDECK_DEBUG_USAGE_RAW=1` opt-in 으로 이미 e61c8fe9 에 배포. 이번 세션의 wake 테스트 로그에서 `5h=21.0%` 정상 리포트 확인됨 → 이전 "0%" 는 저활용 계정의 실제 값이었을 가능성 높음. 향후 재발 시 env var 로 즉시 진단 가능.

### 검증 방법
- **Build**: `xcodebuild -scheme AgentDeck_macOS -configuration Debug build` → BUILD SUCCEEDED.
- **Runtime**: D200H 연결 상태로 앱 실행, 모니터 슬립/웨이크. 로그 확인 포인트:
  - `D200H wake recovery — full teardown + restart` 이후 `D200H reconnected after wake` 5~6초 내 발생 ✓
  - `[Lifecycle] bridge data stale (Ns) — reconnecting` 메시지 없음 ✓
  - Wake 복구 중에도 `[D200H] BROADCAST sessions_list`, `Pixoo] Healthy`, `[Daemon] Usage Tier 3 OK` 계속 들어옴 ✓

### 변경 파일
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` — `HIDRunLoopThread` 추가, `start()` schedule 타겟 교체, `stop()` unschedule 타겟 교체.
- `apple/AgentDeck/State/AgentStateHolder.swift:57` — `wakeGracePeriodSec` 10 → 3.

---

## 2026-04-17 — App Store 출시 감사 + Rendering 모듈 부트스트랩

### 문제
macOS 앱을 App Store에 제출 가능한 상태로 끌어올리기 위한 전수 점검이 필요했다. 동시에 Stream Deck/ESP32/D200H 같은 하드웨어 없는 일반 사용자의 온보딩이 "No devices connected" 공백으로 시작하는 UX 문제도 해소 대상.

### 해결

**감사 리포트** (plan: `~/.claude/plans/agentdeck-parallel-muffin.md`):
- 네 영역 병렬 점검(앱스토어 적합성 / 메뉴바·설정 / 미구현 / 일반 사용자 온보딩) + creature-simulator 데모의 앱 내 통합 가능성.
- **5개 App Store blocker 식별**: (B1) `SessionLauncher.swift:345-352` Process() subprocess spawn, (B2) `AgentDeck.entitlements:17-23` home-relative 경로 exception, (B3) `HookInstaller.swift:17` Claude 훅 자동 설치, (B4) `Info.plist:32-34` + `entitlements:15-16` network.server + Bonjour, (B5) `Info.plist:25` macOS 타겟에 `LSRequiresIPhoneOS=true`.
- **B5 실측 재분류**: xcodebuild `ProcessInfoPlistFile` 단계가 "removing entry for 'LSRequiresIPhoneOS' - not supported on macOS" 로 자동 제거함을 확인 → 즉시 submission 차단 수준 아님, 명시성 개선은 여전히 권장.
- **온보딩 공백**: `ControlTowerPanel.swift:492-506` 빈 상태 배너, `MonitorScreen.swift` 빈 수족관, APME 발견성 저조, 세션 종료 후 리포트 자동 노출 없음, 브랜드 메시지("Stop Chatting. Start Steering.") 앱 내 전무.
- **결정**: Device Preview 는 **SwiftUI 네이티브 포팅** (WKWebView 임베드 반려 — iOS/iPadOS 공유 자산 확보 + App Review 리스크 최소화). Phase 0(샌드박스) + Phase 1(Preview) **병렬 실행**.

**Track B 부트스트랩** (commit 6eedbd8d):
- `apple/AgentDeck/Rendering/` 신규 그룹. `StateColors.swift` (shared/src/state-colors.ts canonical Swift 포트 + `Color(hex:)` 편의 extension), `SessionSlotRenderer.swift` (renderSessionSlot 의 SwiftUI 네이티브 이식 — 144×144, 그라디언트, inner panel, asking pulse glow, ACT 배지, working spinner, 3열 텍스트).
- 크로스플랫폼(`#if os(macOS)` 가드 없음) — iOS Preview Window 재사용 전제.
- Agent watermark 는 letter-in-circle placeholder — 향후 `shared/src/svg-renderers/agent-logos.ts` 포팅 시 교체.

**Track A 부트스트랩** (commit 6eedbd8d):
- `apple/AgentDeck/Resources/PrivacyInfo.xcprivacy` — NSPrivacyTracking=false + 3개 Required Reason API (UserDefaults CA92.1 / FileTimestamp DDA9.1 / SystemBootTime 35F9.1). `ProcessInfo.processInfo.systemUptime` (DaemonServer.swift:613), `FileManager.attributesOfItem` (DaemonVoiceAssistant.swift:174), UserDefaults 광범위 사용을 근거로 선언.
- macOS Debug 빌드 후 `.app/Contents/Resources/PrivacyInfo.xcprivacy` 번들 포함 확인.

**project.pbxproj 등록**: Rendering PBXGroup 신설, 3 PBXFileReference + 4 Sources PBXBuildFile (iOS+macOS 2쌍) + 2 Resources PBXBuildFile 추가. 이전 DeviceDiagnosticPanel 등록 패턴(e8b1e48e)과 동일.

### 핵심 설계 결정

1. **Device Preview = 네이티브 포팅**. WKWebView 로 기존 `tools/creature-simulator` 를 임베드하는 쉬운 길 대신 SwiftUI 네이티브 채택. 이유: (a) App Review 에서 "원격 콘텐츠 핵심 기능 의존" 리스크, (b) iOS/iPadOS 앱에서도 동일 Preview 재사용 (장기 자산), (c) 번들 크기 +780KB 회피.
2. **두 트랙 병렬**. 트랙 A(`apple/AgentDeck/Daemon/` + entitlements/Info.plist) 와 트랙 B(`apple/AgentDeck/Rendering/` + `UI/Preview/`) 는 파일 충돌 없음. Phase 0 완료 기다리지 않고 Preview 개발 진행.
3. **StateColors는 신설 단독 모듈**. 기존 5개 파일(StatusBadge, ControlTowerPanel, SessionListPanel, D200hHidModule, TerrariumConfig)의 인라인 팔레트는 그대로 둠 — 점진적 마이그레이션. 레거시 지우기는 Device Preview 완성 후.
4. **Privacy manifest 는 보수적 선언**. 실제로 접근하지 않는 카테고리까지 추가하는 것보다 확인된 세 가지만 선언, 추후 필요 시 확장. Required Reason reason 코드는 공식 문서 기준으로만 사용.

### 한계 / 추후 과제
- **#13 (신설)**: iOS AgentDeck_iOS 스킴이 `SettingsScreen.swift:618-633` 의 `ApmeJudgeFoundationModels`/`ApmeJudgeApi` 참조 때문에 선존재 빌드 실패 중 (내 작업 이전 상태). `#if os(macOS)` 가드 누락. 별개 이슈.
- Track A 핵심 blocker (#7 Group Container migration, #8 subprocess 제거, #9 hook opt-in) 미착수 — 다음 세션.
- Track B 렌더링 포팅 남은 항목: PixooRenderer 어댑터(#3 — 기존 `Daemon/Modules/PixooRenderer.swift` 1535 LOC 재사용 얇은 adapter 방식 채택 필요), TerrariumRenderer(#5), 14개 기기 View + DevicePreviewScreen(#6).
- agent-logos.ts 포팅은 별도 작업 — SessionSlotRenderer watermark placeholder 교체 시점.

---

## 2026-04-17 — 모니터 Wake 직후 Dashboard 먹통 진단 + 수정

### 문제
모니터가 슬립에서 깨어날 때 macOS Dashboard가 ~25초간 먹통. 로그에서 `[Lifecycle] system wake — force reconnect` 직후 `[Lifecycle] bridge data stale (24s) — reconnecting preferred local bridge`가 나오며 두 번째 강제 재접속 사이클이 돌아 UI가 깜빡이고 데이터가 끊겨 보임.

### 해결

**AgentStateHolder.swift** — 3가지 안전장치:
1. `handleSystemWake()` 1초 디바운스 — IOKit `kIOMessageSystemHasPoweredOn` + `NSWorkspace.screensDidWake`가 S3→wake에서 둘 다 발화하는 걸 막음
2. `handleSystemWake()`에서 `lastDataReceivedAt = now` 리셋 — pre-sleep 시각이 watchdog 기준으로 남아있는 문제 해결
3. `checkForStaleBridgeData()`에 10초 wake grace window 추가 — 데몬 wake 복구(D200H HID 재생성 + 5s 슬립, ESP32 4× close/open + 2s 슬립, mDNS republish) 진행 중 stale 판정 억제

**UsageAPIClient.swift** — `AGENTDECK_DEBUG_USAGE_RAW=1` 환경변수 opt-in raw JSON dump. 사용자가 5h/7d=0% 보고할 때 실제 응답과 파서 누락을 구분하기 위함.

**project.pbxproj** — 앞선 a8f5dc14 커밋에서 DeviceDiagnosticPanel.swift가 repo에는 있으나 Xcode 프로젝트에 등록 누락 → MonitorHUD.swift에서 `Cannot find 'DeviceDiagnosticPanel' in scope`. PBXFileReference + 2× PBXBuildFile + group membership + 2× Sources build phase 엔트리 추가.

**DeviceDiagnosticPanel.swift** — `private enum DeviceStatus` → `DeviceRowStatus` 리네임. Swift 6에서 `DaemonService.swift`의 internal `DeviceStatus`와 충돌하며 "invalid redeclaration" 에러. `private` 의도와 관계없이 Swift 6가 재선언으로 flag.

### 핵심 설계 결정

1. **Wake 이후의 stale 판정은 "데이터 신선도" 문제가 아니라 "복구 중" 문제** — watchdog를 없애는 대신 grace window로 억제. 복구가 10초 이상 걸리면 여전히 발동.
2. **In-process 데몬의 wake 복구는 Dashboard와 같은 main actor를 공유** — 소켓이 끊겼다기보다 main이 밀려 이벤트 처리 지연. 따라서 소켓 재접속 반복은 오히려 역효과.
3. **Usage 원시 로그는 preference 대신 env var** — UI 없이 Xcode scheme 환경변수 한 줄로 토글. 영구 플래그보다 1회성 진단에 적합.

### 한계 / 추후 과제
- Wake 복구 작업(D200H IOHIDManager 재생성, ESP32 serial 4중 close/open) 자체를 전용 background queue로 옮기는 것은 미처리. 증상은 잡았지만 근본 원인(main actor 점유)은 잔존. D200hHidModule / ESP32Serial이 `@unchecked Sendable`이지만 IOKit/serial 콜백은 main run loop에 스케줄됨.
- Usage 0% 원인 판별은 다음 세션에서 `AGENTDECK_DEBUG_USAGE_RAW=1` 실행 후 확인.

---

## 2026-04-17 — 미구현 영역 전수 구현 (6건)

### 문제
프로젝트 전체 TODO 감사 결과, DEVELOPMENT_LOG Phase 로드맵 중 상당수는 이미 구현 완료였으나 6건의 실질적 미구현이 식별됨:
1. Android terrarium Color.copy() per-frame GC pressure
2. Swift daemon Pixoo preview PNG endpoint 부재
3. Swift daemon device diagnostic UI 부재
4. Stream Deck APME eval 전용 패널 부재
5. Stream Deck Control Tower 전용 패널 부재
6. Daemon↔Session Bridge 간 HTTP polling 의존 (internal WS 미구현)

### 해결

**Android GC (4 files)**: RockFormation LED 색상을 setState()에서 pre-compute, drawCircle alpha 파라미터로 대체. LightRaySystem에 20-bucket alpha cache로 Brush.verticalGradient Color.copy 제거. ColorRenderer/TransitionManager의 환경색을 file-level/companion 상수로 추출.

**Pixoo PNG (DaemonServer.swift)**: `/pixoo/preview` GET endpoint. CoreGraphics 의존 없이 순수 Swift PNG 인코더 구현 (zlib compress + CRC32 table + Adler32 + IHDR/IDAT/IEND chunks).

**Device Diagnostic Panel**: DeviceDiagnosticPanel.swift — ADB/D200H/Pixoo/ESP32 모듈 health를 state_update에 포함하여 SwiftUI HUD에 표시. ModuleHealthState 모델 + BridgeEventParser 수동 JSON 파싱 (Codable 불가 — dict 구조).

**APME Eval Mode (plugin)**: utility-modes/apme.ts — /apme/runs에서 scorecard fetch, eval_result timeline 이벤트 수신, rotate로 eval 순회.

**Control Tower Mode (plugin)**: utility-modes/tower.ts — sessions_list 기반 overview (attention/active/idle 카운트) + rotate로 개별 세션 상세.

**Internal WS (bridge)**: daemon-ws-client.ts — session bridge→daemon 영속 WS. session_push_register/session_push_state 프로토콜. session-aggregator에 push cache (30s TTL) 추가, HTTP polling은 fallback으로 유지.

### 핵심 설계 결정

1. **PNG 인코더**: CoreGraphics import 없이 Foundation zlib + 수동 chunk assembly. macOS daemon은 AppKit 의존 최소화 원칙.
2. **ModuleHealthState는 non-Codable**: statusSnapshot()이 [String: Any] dict 반환 → CodingKeys exclude + BridgeEventParser에서 수동 파싱.
3. **APME/Tower는 새 action이 아닌 utility mode로 구현**: manifest 변경 없이 기존 E1 다이얼 인프라 재활용. enabledModes 설정으로 활성화.
4. **Internal WS는 HTTP 대체가 아닌 보완**: push cache 30s TTL 초과 시 HTTP fallback. 점진적 마이그레이션 가능.
5. **SerialModule은 async → sync health에서 제외**: ESP32Serial.statusSnapshot()이 async이므로 buildModuleHealthSync()에서 존재 여부만 표시.

---

## 2026-04-16 — Timeline monotony fix: PTY fallback + APME eval enrichment

### 문제
Dashboard timeline이 `"Prompt sent" → "Completed · Xs"` 만 반복 표시. Claude Code hook 이벤트(PreToolUse/PostToolUse/Stop)에 100% 의존하는데, Stop hook ~18% 신뢰도로 tool activity가 timeline에서 완전 소실.

### 해결
**Part A — PTY parser → Timeline 연결** (`bridge/src/index.ts` wireClaudeCodeTimeline):
1. PTY `tool_action` → `tool_exec` timeline entry (hook tool_request 미발화 시 2초 디바운스로 대체)
2. PTY `spinner_stop`/`idle` → fallback `chat_end` (Stop hook 1.5초 대기 후 PTY ringbuffer에서 response 추출)
3. `tool_resolved`에 tool name 포함 ("Approved" → "Read approved")
4. `chat_start` 폴백 개선 (project name 포함 + late upsert)
5. `chat_response` 추가 (Stop hook / PTY fallback 양쪽 공유하는 `emitCompletion()` 헬퍼)

**Part B — APME eval ↔ Timeline 연계** (`bridge/src/daemon-server.ts`, `bridge/src/tui/renderer.ts`):
6. TUI renderer `eval_result` 명시 지원 (★ 아이콘 + yellow)
7. Run eval detail에 축 점수 + deterministic 결과
8. Turn eval detail에 judge reasoning (done/missed)
9. Deterministic layer 별도 `⚡` timeline entry
10. Session bridge에서도 turn-level eval_result emit (`runner.onResult` wiring)

### 핵심 설계 결정
- **PTY와 Hook은 경쟁 관계, 둘 다 필요** — Hook이 더 정확하지만 unreliable. PTY는 100% 가용하지만 정보가 덜 구조적. `ccPendingCompletion` 플래그 + 1.5초 타이머로 Hook 우선/PTY 폴백 전략 구현.
- **`emitCompletion()` 헬퍼로 중복 제거** — Stop hook path와 PTY fallback path가 동일한 chat_response + chat_end + LLM summarization 로직 공유. 코드 분기점은 response 텍스트 소스만 다름 (hook: `last_assistant_message`, PTY: ringbuffer ⏺ 마커 추출).
- **Session bridge eval은 turn-level만** — Run-level eval은 deterministic layer(lint/build/test) 포함이라 daemon에서만 처리. Session bridge는 가벼운 turn-level eval만 timeline에 emit.
- **Dedup은 정상 작동 중** — `extractSemanticCore`가 chat_end에서 `·` 이후를 strip → "Completed"가 keyword filler로 빠져 empty set → `isSimilarCore` false. 문제는 dedup이 아니라 이벤트 자체가 빈약했던 것.

---

## 2026-04-16 — Swift Pixoo module circuit breaker

### 문제
Pixoo 기기 무응답 시 Swift daemon이 333ms마다 2s timeout HTTP 요청을 무한 생성 → URLSession pool starvation → BridgeConnection WS ping cancelled → "bridge data stale" → reconnect loop → Timeline에 Connected/Disconnected 반복. Node.js bridge에는 circuit breaker가 있었으나 Swift daemon에는 없었음.

### 해결
Node.js bridge 패턴(`pixoo-client.ts:40-157`) 포팅: 6회 연속 실패 → exponential backoff (5s–60s cap), 별도 probe Task가 10초마다 `GetAllConf`로 복구 감지, 성공 시 PicID resync + channel 재설정 후 push 재개. `statusSnapshot()`에 per-device `online`/`backedOff`/`failures` 추가.

### 핵심 설계 결정
- Pixoo HTTP API에 reboot 명령이 없으므로, circuit breaker + probe가 최선의 자동 복구 전략
- Backoff 진입 시 사용자 안내 로그: `"Power-cycle the device if it doesn't recover."`
- probe 주기 10초 (Node.js의 5초보다 여유 — Swift는 per-request ephemeral session이라 리소스 비용 더 높음)

---

## 2026-04-14 — APME parity: OpenClaw/Codex turn_judge 누락 수정

### 문제
2026-04-13 APME 범용화 이후에도 비-Claude 세션의 실시간 턴 평가가 안 돌고 있었다. `turns.task_category`, `turns.composite_score`는 NULL, `evals.layer='turn_judge'` 행은 OpenClaw/Codex/OpenCode 세션 ID로 조회하면 0건. Claude Code 세션만 제대로 기록됨.

### 해결
`bridge/src/index.ts`의 mid-session classify + turn enqueue 로직이 Claude PTY `spinner_stop` 핸들러 내부에만 인라인으로 존재했음. 공용 헬퍼 `classifyAndEnqueueTurn(apme, sid)`로 추출하고 세 경로에서 동일하게 호출:
1. Claude `spinner_stop` (기존 인라인 37줄 → helper 호출 1줄)
2. OpenClaw/OpenCode `chat_response` (timeline event, 신규)
3. Codex `spinner_stop` (PTY parser, 신규)

### 핵심 설계 결정
- **단일 헬퍼 공유**: agent-type 분기를 헬퍼 안에 넣지 않음. 분류 규칙(`classifyRun`)과 NON_CODE 카테고리 세트는 모든 에이전트에 동일 적용. 에이전트별 동작 차이가 필요해지면 그때 분기.
- **`chat_end` fallback 경로는 커버하지 않음**: `setLastClosedTurnResponse`는 이미 closed turn을 수정. helper는 `getActiveTurnId` 기준이라 조기 return됨. Claude도 `spinner_stop`을 놓치면 동일하게 daemon `closeRun`의 async 경로에 맡기므로 parity 유지.
- **Commit**: 23df3b44 `fix(apme): parity classify+turn_judge for OpenClaw/OpenCode/Codex`

---

## 2026-04-13 — APME 범용화: 버그 수정 + 멀티 에이전트 + 턴 단위 평가 + 타임라인 통합

### 문제
APME 대시보드에서 세션 평가 시 4가지 데이터가 표시되지 않음 (response, category, score, approve). 또한 APME가 Claude Code 전용으로, OpenCode/Codex/OpenClaw 세션은 turns/prompts/responses 미기록. 평가 결과가 Stream Deck 등 디바이스에 전혀 보이지 않음.

### 해결
**Phase 1 — 버그 수정 (6개)**:
- Response 캡처: `Stop` hook이 Claude Code v2.1.104에서 불안정 (11회 중 2회만 발화). PTY `spinner_stop` 이벤트 후 500ms 딜레이 → `⏺` 마커 이후 clean text 추출로 fallback. Race condition (idle/hook 순서) 대응: pendingPtyResponse buffer + closedTurn fallback 3경로 설계.
- Category/Score: 세션 브리지 프로세스 종료 시 fire-and-forget async가 kill되는 문제 → 데몬 타이머에 classify/orphan cleanup 단계 추가.
- Port 충돌: `findAvailablePort({ reserveDaemon: true })` — 세션 브리지는 9121+, 9120은 데몬 전용.
- Turn index: `closeTurn()` 후 `sessionToTurn` delete → 항상 index=0 버그 → delete 전 index 읽기.
- `/clear` → `splitRun()`: 컨텍스트 리셋 시 run 분리.

**Phase 2 — 카테고리별 평가 루브릭 (7종)**:
coding(기존), conversation(accuracy/helpfulness/conciseness), planning(completeness/feasibility/clarity), research(thoroughness/relevance/synthesis), debugging(diagnosis/fix_quality/verification), refactoring(safety/improvement/scope), review(coverage/insight/accuracy), ops(correctness/safety/completeness).

**Phase 3 — 멀티 에이전트 APME 지원**:
`wireAgentApme()` — OpenCode/OpenClaw의 `source:'timeline'` 이벤트, Codex의 PTY parser 이벤트에서 turns/prompts/responses 수집. 비코딩 카테고리 outcome: 응답 완료 = `committed` (git 불필요).

**Phase 4 — 턴 단위 즉시 평가**:
`runner.enqueueTurn()` — 세션 진행 중 각 턴 완료 직후 conversation 루브릭으로 judge 실행. `evals` 테이블 `layer='turn_judge'` + `turn_id` 연결. 대시보드 Turn 카드에 score + reasoning 표시.

**Phase 5 — 타임라인 통합 (전 디바이스)**:
`eval_result` TimelineEntryType 추가. `runner.onResult()` 콜백에서 `apme_eval` WS broadcast + `bridgeTimeline.addEntry()`. Stream Deck(★ amber), Apple(★ ledAmber EVAL), Android(★ LEDAmber EVAL), ESP32(@ TLToolReq) 전부 렌더러 대응.

### 핵심 설계 결정
- **Claude Code `Stop` hook 불안정** — 11회 중 2회만 발화. PTY `spinner_stop` + `⏺` 마커 파싱이 현재 유일한 신뢰 가능 경로. Stop hook이 오면 더 깨끗한 `last_assistant_message`로 덮어씀.
- **PTY response 캡처 race** — 간단한 응답(1+1=2)에서 `spinner_stop`이 `UserPromptSubmit` hook보다 먼저 도착. `pendingPtyResponse` buffer → hook 도착 시 적용하는 3경로 패턴.
- **카테고리별 루브릭** — `rubrics` 테이블의 `purpose` 필드로 구분. `runner`가 `store.getCurrentRubric(taskCategory)` → fallback `'general'`.
- **비코딩 outcome** — conversation/planning/research/review는 git commit 없이도 turns 존재 시 `committed` (score 1.0). 코딩 세션만 git diff 기반.
- **턴 단위 eval** — `turn_judge` layer. 비코딩 카테고리만 (코딩은 git diff 필요하므로 세션 종료 후). Judge prompt에 turn prompt+response만 포함 (전체 diff 없음).

---

## 2026-04-13 — Fix missing model names in OpenClaw/OpenCode sessions

### 문제
macOS Dashboard에서 OpenClaw 모델명이 전혀 표시되지 않음. Android/iOS에서도 세션 목록의 OpenClaw 모델명이 빈칸.

**root causes**:
1. (RC-1) session-aggregator.ts의 own-session 엔트리가 modelName을 포함하지 않아서 sessions_list 수신 시 항상 undefined → 모든 adapter 영향
2. (RC-2) OpenCode는 첫 assistant message 전까지 modelID를 얻을 수 없음 (by design — API에서 session 객체에 modelID 미노출)
3. (RC-3) OpenClaw catalog probe(`openclaw models list --json`) 실패 시 emitModelCatalog()에서 null 반환 → model_info 발행 안 됨

### 해결

**Fix 1** (RC-1): session-aggregator.ts + bridge-core.ts
- `enrichSessionsWithState()` 와 `buildEnrichedSessionsList()` 에 `ownModelName?: string` 파라미터 추가
- self-entry 생성 시 modelName 포함: `{ ...base, state: ownState, modelName: ownModelName }`
- bridge-core.ts의 두 호출부에서 `snapshot.modelName ?? undefined` 전달

**Fix 2** (RC-2): OpenCode — 현재 behavior 유지
- OpenCode API가 session의 model을 미노출하므로 현 design (first message 이후 modelID 수신) 이 최적
- Fix 1의 sessions_list 개선으로 자신 세션의 modelName도 state_update에 반영되면 Dashboard에 나타남

**Fix 3** (RC-3): openclaw.ts — fallback modelId 추출
- `chat.final` event handler 에서 payload의 `model` 또는 `modelId` 필드 검사
- 있으면 model_info 발행 (catalog probe 실패 시 최소한 첫 response 이후 model명 표시됨)

### 검증
- session-aggregator tests: ✅ 6 passed
- bridge-core-sessions tests: ✅ 2 passed
- adapter tests: ✅ 93 passed
- output-parser tests: ✅ 221 passed
- TypeScript build: ✅ all packages compiled

---

## 2026-04-12 — Creature simulator parity + D200H v8 readability

### 문제
- `tools/creature-simulator`의 e-ink/ESP32/TC001/D200H preview가 실제 렌더러가 아니라 HTML 전용 근사치라 drift 발생.
- e-ink preview는 1872x1404 기준 높이 비례 폰트가 과대 적용되어 좌측 텍스트와 레이아웃이 실제 Android Compose 화면과 다름.
- TC001 preview에 실제 AGENTS 페이지에는 없는 agent 하단 상태 점이 표시됨.
- D200H 세션/usage 텍스트가 실기기에서 작고, usage 색상 룰이 Pixoo/TC001 계열과 다름.

### 해결
- D200H renderer rev를 `stock-safe-v8`로 갱신하고 세션 키 텍스트/상태 라벨을 확대, idle 상태에서도 agent 브랜드 스트립을 표시.
- D200H usage merged PNG를 `LIMITS` 중심 레이아웃으로 재구성하고 blue/teal/amber/red 사용량 색상 룰로 통일.
- TUI simulator data를 160x40 기준 actual `renderDashboard()` 출력으로 재생성하고 ANSI 색 span을 보존해 실제 terminal 색상과 더 가깝게 표시.
- e-ink simulator preview를 capped font 기반 패널로 교체하여 실제 Compose 화면과 비슷한 밀도 유지.
- ESP32 simulator preview를 실제 LVGL HUD panel 크기/위치에 맞춰 landscape/portrait/round를 분기.
- TC001 simulator sprite와 layout을 `matrix_pages.cpp` 기준으로 맞추고 AGENTS 페이지 하단 dot 제거.

### 검증
- `node --check scripts/render-creature-simulator.mjs` 성공.
- `swiftc -parse apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` 성공.
- `pnpm --filter @agentdeck/bridge typecheck` 성공.
- `playwright screenshot --full-page http://127.0.0.1:8799/index.html /tmp/agentdeck-creature-simulator-after-tui.png` 성공.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'generic/platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HVisualParity build` 성공.
- D200H 실기 송신 덤프에서 `icons/btn0-stock-safe-v8-*.png`, `icons/btn13-wide-stock-safe-v8-*.png` 생성 확인.

---

## 2026-04-12 — APME (Agent Performance Monitoring & Evaluation) module

### 문제
에이전트 세션의 작업 품질을 평가하고 모델 간 성능을 비교할 수 없었음. "이 모델이 이 유형의 작업에 더 나은가?" 같은 질문에 답할 데이터가 없음.

### 해결
APME 모듈 전체 구현 — SQLite store, collector, eval runner (deterministic + LLM judge), rubric auto-tuner, outcome detector, efficiency calculator, composite scorer, task classifier, web dashboard, CLI 11개 서브커맨드.

### 핵심 설계 결정

1. **에이전트 기여(delta) 중심 평가**: 프로젝트 절대 상태(테스트 pass/fail)가 아닌, 에이전트가 세션 전후로 만든 변화를 평가. 기존 빌드 에러가 있어도 에이전트 기여는 정확히 측정.

2. **Outcome signals > Vibe checks**: 사용자의 approve/reject 는 노이즈 (탐색적 실행, A/B 테스트 등). committed/abandoned/iterated 같은 행동 신호가 더 신뢰성 높음. Composite = outcome(0.4) + judge(0.3) + efficiency(0.2) + vibe(0.1).

3. **Turn-level evaluation**: 세션 단위가 아닌 프롬프트 단위 추적. `UserPromptSubmit` → turn open, 다음 prompt 또는 session_end → turn close. 각 turn 에 prompt + response + tool stats.

4. **MLX 기본, API opt-in**: 모든 LLM 작업(judge, classifier)은 로컬 MLX 서버 기본. API 비용 0원. 사용자가 명시적으로 opt-in 해야 Anthropic API 사용.

5. **Hook + PTY parser 이중 수집**: Claude Code v2.1.104 에서 `UserPromptSubmit` hook 이 fire 안 됨. `PreToolUse`/`PostToolUse` 는 `matcher: "*"` 필요 (빈 문자열은 매치 안 함). PTY parser 의 `user_prompt` metadata + `tool_start`/`tool_end` parser event 가 fallback.

6. **Swift-native APME**: App Store 배포를 위해 Swift daemon 에도 ApmeStore/Collector/Classifier/HttpRoutes 구현. Node.js 와 같은 `apme.sqlite` 공유 (WAL mode). 독립 동작 가능.

7. **Channels/MCP 는 불필요**: Channels 는 외부→Claude 방향(이벤트 주입)이고, APME 는 Claude→외부 방향(데이터 수집). 목적이 반대.

### 교훈

- **Node.js zombie 프로세스**: hook-server SSE heartbeat `setInterval` 에 `.unref()` 누락 → event loop 이 안 끝남 → 포트 점유. 모든 shutdown 경로의 timer 에 `.unref()` 필수.
- **NWListener NECP 에러**: 짧은 시간 내 listener 생성/파괴 시 macOS 커널의 NECP path 업데이트 실패 → `.failed` state 방출 → daemon shutdown. Non-fatal 이므로 무시해야 함.
- **Hook matcher**: Claude Code 의 tool-specific hook (PreToolUse/PostToolUse) 에서 `matcher: ""` = "match nothing". `matcher: "*"` 사용 필수.
- **message.content**: Claude Code v2.1+ 의 UserPromptSubmit 은 `{ message: { content: "..." } }` 형태. `{ prompt: "..." }` 아님.

---

## 2026-04-12 — Device disconnect UX + daemon TDZ crash

### 문제
1. **Pixoo 데몬 종료 시 화면 멈춤**: push-only HTTP 기기라 데몬이 꺼지면 마지막 terrarium 프레임에서 정지 — 사용자에게 상태 불명확
2. **ESP32 "NO WIFI" 오표시**: TC001 + LVGL 3종이 데몬 종료 시 "NO WIFI" 표시. 실제 문제는 daemon 오프라인인데 WiFi 상태로 판단하는 로직 오류
3. **D200H 화면 미표시**: `daemon-server.ts`의 `gatewayAdapter`가 HTTP 서버 핸들러보다 뒤에 `let` 선언 → ESP32/앱이 서버 시작 직후 `/health` 요청 → TDZ ReferenceError로 데몬 crash → D200H 모듈 초기화 불가

### 해결
1. **Pixoo**: `stopPixooBridge()`를 async로 변경, 종료 전 검은 배경 + 회색 "OFFLINE" 프레임을 push (2s 타임아웃)
2. **ESP32 TC001**: `buildDisconnectMsg()` — `everGotData` true면 항상 "OFFLINE" (WiFi 무관). LVGL boards: `everConnected` 체크를 WiFi 체크보다 우선
3. **daemon TDZ**: `gatewayAdapter` + `gatewayConnecting` 선언을 HTTP 서버 설정 전으로 이동

### 핵심 설계 결정
- ESP32 disconnect 판단: `lastMessageMs != 0` (한 번이라도 데이터 받았음) → 항상 "OFFLINE". WiFi 상태와 무관하게 "daemon이 사라졌다"가 정확한 상태
- Pixoo는 push-only이므로 정적 프레임만 가능 (ESP32처럼 breathing 애니메이션 불가)
- TDZ 교훈: `let`/`const`는 같은 함수 스코프여도 선언 라인 이전에 클로저에서 접근하면 crash. HTTP 핸들러처럼 비동기로 호출되는 클로저는 변수 선언 순서에 민감

---

## 2026-04-12 — Focus relay sessionId 전파 + Android derivedStateOf 수정

### 문제
1. **macOS Dashboard 크리처 중복**: Focus relay가 sibling의 `state_update`를 broadcast하면 client `state.agentType`이 변경되지만 `state.sessionId`는 daemon의 ID로 남아 있음. TerrariumState dedup 필터 `!(primaryIsX && $0.id == sessionId)`가 sessionId 불일치로 실패 → 같은 세션이 primary + sibling 이중 렌더
2. **Android 크리처 미표시**: `MonitorScreen.kt`의 `derivedStateOf { dashState.toTerrariumState() }`가 초기 `dashState` 캡처 후 siblingSessions 변경을 반영하지 않음 (commit 2315206b에서 EinkMonitorScreen만 수정, MonitorScreen 누락)

### 해결
- `StateUpdateEvent`에 `sessionId` 필드 추가 (shared protocol + Swift Protocol.swift)
- Swift daemon `DaemonServer.swift` focus relay broadcast 콜백에서 `focusRelay.focusedSessionId` 주입
- Node.js daemon `daemon-server.ts` focus relay handler에서 동일하게 주입
- Swift `AgentStateHolder.handleStateUpdate()`에서 `state.sessionId` 업데이트
- Android `MonitorScreen.kt`: `remember { derivedStateOf { ... } }` → `remember(dashState) { ... }`

### 핵심 설계 결정
- **sessionId는 state_update에 포함**: focus relay가 promote하는 세션의 ID를 client에 전달. Connection event는 relay하지 않으므로 state_update에 piggyback
- **Unfocus 시 별도 처리 불필요**: daemon이 자체 state broadcast → agentType="daemon" → primaryIsX=false → dedup 미적용 → stale sessionId 무해
- **Android `keepAggregateIdentity` 보호**: Android AgentState.kt의 기존 로직이 focus relay 시 agentType 변경을 이미 차단하므로, Android에서는 duplicate creature 미발생 → Android Protocol.kt에 sessionId 전파 불필요

---

## 2026-04-12 — D200H usage panorama density + billing label

### 변경
- D200H stock HID 사용량 영역을 `stock-safe-v7`로 갱신.
- 5H/7D 리셋 시간을 11pt 보조 텍스트에서 17pt 주요 텍스트로 키우고, 24시간 이상은 `5d`, `1d4h` 같은 날짜 기반 compact 표기로 환산.
- 사용량 리셋 텍스트의 `LEFT` suffix를 제거하고, 진행 바를 아래로 내려 병합 2칸의 세로 공간을 더 사용.
- `subscriptions[].until` 또는 `codexSubscriptionActiveUntil` 값이 있으면 하단에 `ChatGPT Plus Apr 19`처럼 서비스명과 다음 구독 날짜만 표시. `RENEW` prefix는 사용하지 않음.
- D200H OpenClaw 타일은 별도 Swift path renderer를 타므로 icon rect를 키우고 renderer rev를 올려 실제 PNG와 파일명이 함께 바뀌도록 조정.
- D200H의 virtual OpenClaw gateway 세션은 버튼 제목을 `Gateway`가 아니라 `OpenClaw`로 표시하고, 모델명이 없을 때 상태 텍스트가 실제 버튼 하단 마스크에 붙지 않도록 텍스트 stack을 위로 이동.
- D200H 재부팅 직후 기본 시계 small-window 레이어가 13번 merged usage 영역에 겹치는 문제를 막기 위해, `3_2` usage manifest에서 `com.ulanzi.ulanzideck.smallwindow.window` action을 제거하고 빈 action으로 명시적으로 clear.
- `tools/creature-simulator/index.html`의 D200H merged usage preview도 동일한 리셋/구독일 샘플과 OpenClaw 텍스트 위치 조정을 반영.

### 검증
- `swiftc -parse apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` 성공.
- `node --check scripts/render-creature-simulator.mjs` 성공.
- `pnpm --filter @agentdeck/bridge typecheck` 성공.
- `git diff --check -- apple/AgentDeck/Daemon/Modules/D200hHidModule.swift bridge/src/d200h/image-renderer.ts tools/creature-simulator/index.html DEVELOPMENT_LOG.md` 성공.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'generic/platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HUsagePanoramaV5 build` 성공.
- Runtime `/status`: `d200h.rendererRev=stock-safe-v7`, `connected=true`, `writeFail=0`, `managerOpened=true`.
- 최신 D200H dump `20260412-134326-178-set_buttons-L-52139b-OPENCLAW___.zip`: `manifest["3_2"]`가 `Action=""`인 일반 icon entry로 `icons/btn13-wide-stock-safe-v7-2f203781.png` 참조, PNG 크기 392x196. 실제 PNG는 `LEFT`/`RENEW` 없이 `2h17m`, `4d20h`, `ChatGPT Plus Apr 19`로 렌더링.
- stock-safe-v16: D200H 2칸 usage 버튼을 Stream Deck+ overview 정보 구조에 맞춰 재구성. `USAGE` 헤더, 5H/7D segment gauge, percent, reset time, 구독 결제일, 하단 accent bar만 남기고 D200H safe area에 맞춰 2배 스케일로 배치.
- stock-safe-v17: V16 usage 레이아웃을 소폭 상단 이동하고 하단 구독 결제일/색상 accent가 D200H 실제 버튼 하단 경계에 붙지 않도록 bottom safe area를 확장. 시뮬레이터 D200H 텍스트도 Swift 렌더러와 같은 HelveticaNeue 계열로 통일.
- stock-safe-v19: D200H usage 하단 accent bar가 실제 버튼 경계선처럼 보여 텍스트와 하단 마스크를 더 붙어 보이게 하므로 제거. 구독 결제일 텍스트만 위쪽 safe area에 배치.

---

## 2026-04-12 — Sibling session state cache (OpenClaw flicker fix)

### 문제
Android 태블릿/e-book에서 OpenClaw sibling 세션이 간헐적으로 비정상 상태로 표시됐다가 금방 복구. 모든 기기가 동시에 영향 받음.

### 해결
`session-aggregator.ts`에서 sibling `/health` fetch 실패 시(2초 타임아웃) `state: undefined`를 그대로 전파하던 것이 원인. `siblingStateCache` (Map<sessionId, {state, modelName}>)를 추가하여 fetch 성공 시 캐시 저장, 실패 시 캐시된 last-known state 반환. `session-registry.ts`의 `deregister()`에서 캐시 정리.

### 핵심 설계 결정
**Sibling state는 stale > undefined.** 10초 폴링 주기에서 1회 타임아웃은 최대 10초의 stale state를 의미하지만, undefined 전파는 모든 클라이언트에서 크리처 깜빡임을 유발. Stale이 UX 관점에서 항상 낫다.

---

## 2026-04-12 — D200H Stable Stock HID Profile

### 문제
실제 D200H에서 preview/dump와 다른 화면이 표시됨. `13L`이 `13R` 영역까지 늘어나 보이고, 2번 셀에는 현재 렌더가 아닌 이전/다른 재생 아이콘이 보였다. daemon status는 HID write 성공(`writeOK` 증가, `writeFail=0`)이므로 USB 전송 실패보다는 stock firmware의 manifest/resource 해석 및 캐시 문제로 판단.

### 해결
- D200H stock HID 경로를 안정성 우선 프로파일로 고정.
- 모든 아이콘 파일명을 `icons/btnN-<content-hash>.png` 형태로 만들어 펌웨어가 같은 경로의 stale bitmap을 재사용하지 못하게 함.
- 병합 영역은 stock dump와 맞춰 `3_2` 단일 entry만 사용하고 `Action=com.ulanzi.ulanzideck.smallwindow.window`를 유지. `4_2` entry를 제거하고, `3_2` icon 자체를 2칸 폭 wide PNG로 생성.
- 부분 업데이트, press flash, animation frame 전송을 끔. D200H는 Stream Deck 같은 per-key dynamic surface가 아니라 stock 앱의 manifest-driven 화면으로 취급.
- preview dump 스크립트도 manifest의 `ViewParam.Icon`을 읽도록 변경해 hash 기반 파일명과 wide icon을 그대로 확인하게 함.

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HStableHid build CODE_SIGNING_ALLOWED=NO` 성공.
- 실행 중인 daemon status: `d200h.connected=true`, `stableStockHid=true`, `partialUpdatesEnabled=false`, `writeFail=0`.
- 최신 dump `20260412-080432-812-set_buttons-L-50194b-GATEWAY_OPENCLAW__.zip`: hash 기반 icon filename 사용, `3_2`는 `icons/btn13-wide-da0dd945.png`를 참조, `4_2` entry 없음.
- `btn13-wide-da0dd945.png` 크기: 392x196. 일반 버튼 아이콘은 196x196.

### 핵심 설계 결정
공식 앱 없이 HID manifest만 흉내내는 방식은 D200H에서 저수준 화면 제어가 아니다. 안정화 목표는 "현재 상태가 틀리지 않게 보이는 것"이며, Stream Deck 수준의 부분 갱신/프레스 피드백/애니메이션은 이 프로파일에서 제외한다. 이 변경 후에도 실기기 화면이 계속 preview와 다르면 D200H stock HID 지원은 best-effort로 낮추고 Android 내부 앱/프레임버퍼 경로나 다른 하드웨어 연동 방식을 우선 검토한다.

---

## 2026-04-12 — CremaS/Pantone creature state divergence — derivedStateOf stale closure

### 문제
CremaS와 Pantone6이 같은 daemon에 연결됐지만 CremaS에서 크리처가 0마리(agents=0, clouds=0, oc=0)로 표시되는 반면 Pantone6에서는 정상(agents=1, clouds=1, oc=1). 헤더/사이드바에는 양쪽 모두 세션이 표시됨.

### 해결
`EinkMonitorScreen.kt`의 landscape(line 222)/portrait(line 649) 두 곳에서:
```kotlin
// Bug: derivedStateOf captures the initial parameter value, never re-evaluates
val terrariumState by remember { derivedStateOf { state.toTerrariumState() } }
// Fix: state as key forces recomputation on every change
val terrariumState = remember(state) { state.toTerrariumState() }
```

### 핵심 설계 결정
**Compose `derivedStateOf` + 함수 파라미터 = 스테일 클로저 함정.** `remember { derivedStateOf { param.something() } }`에서 `param`이 함수 파라미터(plain value)면 첫 composition 값에 고착된다. `collectAsState()` 위임 속성은 Compose State로 추적되어 정상 작동하지만, 그 값을 자식 composable에 파라미터로 넘기면 추적이 끊긴다. 해결: `remember(param)` 또는 `rememberUpdatedState`.

**기기 간 차이가 나는 이유는 레이스 컨디션.** `sessions_list`가 `state_update`보다 비동기로 늦게 도착(Promise 기반). 첫 Compose 프레임이 sessions_list 전에 실행되면 빈 siblingSessions 캡처 → 영구 고착. Compose choreographer 타이밍에 따라 기기마다 결과가 달라짐.

---

## 2026-04-12 — Usage Stale Fallback + Display-Wake Recovery

### 문제
1. **재시작 후 usage 빈 화면** — `fetchUsage()` 가 file cache stale + network fail 시 캐시 데이터를 버리고 nil 반환. backoff 겹치면 90초+ 동안 usage 없음.
2. **`!` 깜빡임** — `fetchUsageRelayed()` 실패 경로에서 매번 `apiUsageStale=true`. 2분 전 캐시여도 네트워크 한 번 실패하면 `!`.
3. **모니터 off→on 후 stale 상태** — `DisplayMonitor` wake 콜백이 `display_state` broadcast 만 수행. serial reconnect / 모듈 wake / state 재방송 없음. `kIOMessageSystemHasPoweredOn` 은 system sleep 에만 울림.
4. **serial errno=6 잔상** — 포트 재오픈 성공해도 `lastReadError` 가 `closeAllConnections()` 에서만 초기화.

### 해결
- `UsageAPIClient.fetchUsage()`: `staleFallback` 변수. 모든 `return nil` → `return staleFallback`.
- `DaemonServer.fetchUsageRelayed()`: 실패 경로의 `apiUsageStale = true` 2건 제거. 10분 TTL 만으로 stale 판단.
- `DaemonServer` display wake 콜백: `moduleManager.wakeAll()` + session prune + state broadcast 추가.
- `AgentStateHolder`: `NSWorkspace.screensDidWakeNotification` 구독 추가 (IOKit system-wake 와 병행).
- `ESP32Serial.openAndRegisterPort()`: `lastReadError = nil`.

### 핵심 설계 결정
- **stale ≠ unavailable**: 캐시 usage 는 네트워크 일시 장애와 무관하게 유효. `!` 는 10분+ 갱신 불가 시에만.
- **display sleep ≠ system sleep**: 모니터만 끄는 건 `kIOMessageSystemHasPoweredOn` 을 발생시키지 않음. `screensDidWakeNotification` (대시보드) + `DisplayMonitor` CoreGraphics 폴링 (데몬) 으로 각각 잡아야 함.

---

## 2026-04-12 — CremaS "크리처 미표시" 진단 — 실제 원인은 WS 접속 실패

### 문제
CremaS e-ink에서 터라리엄 크리처가 하나도 안 보인다는 보고. 크리처 렌더링 버그로 의심했으나, 실제 원인은 전혀 다른 계층에 있었다.

### 진단 과정
1. `TerrariumState.toTerrariumState()` → `EinkRenderer.renderEinkFrame()` → `EinkMonitorScreen` 코드 경로 전수 분석: **CremaS 전용 분기 없음**, 크리처 렌더링 코드에 문제 없음
2. logcat `EinkFrame agents=0` → screencap: 화면이 "Reconnecting... Attempt 5, Failed to connect to /192.168.68.199:9120" 상태에 갇혀 있었음
3. **`EinkMonitorScreen.kt:208`** 분기에서 `isReconnecting && agentState==DISCONNECTED`일 때 `EinkReconnectingScreen`만 렌더 → **aquarium 자체가 화면에 없음**
4. 앱 재시작 시 `ws://127.0.0.1:9120` (USB adb reverse)로 정상 연결 → 크리처 4종 렌더 정상 확인
5. 초기 auto-connect 시퀀스(`EinkMonitorScreen LaunchedEffect`)가 1회성 — Reconnecting 루프에 빠지면 localhost/mDNS 재시도 없이 저장된 WiFi URL만 반복

### 해결 (최소 변경, fallback 로직 미추가)
- **`BridgeConnection.onFailure`**: `t.message`만 저장 → `response: Response?`의 HTTP code/message + exception 클래스명 포함. "Failed to connect"만 뜨던 것 → "ConnectException: Failed to connect to ..." 로 원인 명시
- **`EinkReconnectingScreen`**: 에러 메시지를 "Stop Reconnecting" 버튼 아래(화면 끝 잘림) → 버튼 위의 bordered Surface로 이동, "Connection error" 라벨 추가

### 핵심 교훈
- "렌더링 안 됨" 보고 시 크리처 코드 전에 **접속 상태부터** 확인 — screencap 한 장이 logcat 100줄보다 빠른 진단
- monitor-only 기기(CremaS, Pantone, Lenovo)는 daemon WS 접속 실패 시 크리처 아닌 Reconnecting 화면이 보이므로 "크리처 미표시"로 오인됨
- `EinkFrame` 로그가 찍히더라도 Compose composable이 화면에 없으면 사용자에게는 안 보임 (백그라운드 애니메이션 루프)

---

## 2026-04-12 — Daemon Self-Probe Restart Loop + D200H IOHIDManagerOpen + iPad IPv6 Discovery

### 문제
in-process Swift daemon 이 매 ~60초마다 자기 자신을 셧다운하고 재시작하는 루프에 빠져 있었다. D200H HID module 은 `managerOpened=true, connected=false` 상태에 고착해 버튼이 영구 뜨지 않았다. iPad 앱은 Bonjour 로 daemon 을 찾고는 "Invalid URL" 에러로 접속 실패.

### 원인 (세 개가 완전히 독립)

**1. `DaemonService.checkDaemonHealth()` 의 self-HTTP-probe 안티패턴**
`URLSession.shared.data(for: http://127.0.0.1:9120/health)` 를 5초마다 돌리고 2초 timeout → 2연속 실패 시 `server.shutdown() + start()`. URLSession 이 다른 호스트(죽은 sibling 9121/9122/9123, Pixoo 192.168.68.110 느린 응답) 타임아웃으로 혼잡해지면 loopback self-probe 도 덩달아 2초 넘어가면서 false-positive 재시작 발동. 재시작하면 D200H/ESP32/Pixoo 모듈이 전부 재초기화되고 D200H 의 IOKit matching 콜백은 첫 tick 안에 발화해야 하므로 연결 기회를 영구 상실.

**2. `D200hHidModule.start()` 에서 `IOHIDManagerOpen` 누락**
`a75b6da6` 최초 커밋에는 있었지만 `d2c04ea0 feat(d200h): multi-session agent controller` 에서 "개별 device 를 `handleDeviceAttached` 에서 `IOHIDDeviceOpen` 할 거니 manager open 은 불필요" 라는 잘못된 판단으로 제거됨. 하지만 IOKit HID 의 device matching 콜백은 **manager 가 open 상태**여야 already-present device 에 대해 발화한다. 그래서 schedule + matching dict 는 정상 설정되는데 **첫 `handleDeviceAttached` 콜이 평생 안 찍힘**. `managerOpened=true` 는 플래그만 무조건 올려두던 옛 코드였고, `lastOpenError=0` 이라 진단 불가능.

**3. `BridgeDiscovery` 의 IPv6 link-local + bracket 누락**
Mac 이 Bonjour 로 `daemon-9120` 를 광고할 때 여러 인터페이스(Wi-Fi, VPN utun, awdl 등) 주소가 함께 올라온다. `resolveEndpoint` 가 `NWConnection(to:using:.tcp)` 를 aggregate endpoint 로 열면 link-local IPv6 (`fe80::...%en0`) 에 먼저 붙는 경우가 생긴다. 거기서 `%en0` zone suffix 를 잘라내고 `DiscoveredBridge(host: "fe80::...")` 로 저장 → `wsUrl = "ws://fe80::...:9120"` ← **RFC 3986 위반** (IPv6 literal 은 bracket 필수). Foundation `URL(string:)` 이 nil 리턴 → `BridgeConnection:95` 의 "Invalid URL: ..." 표시.

### 해결

**1. Daemon self-probe 제거** (`DaemonService.swift:526`)
`isUsingExternalDaemon == false` 일 때는 `isRunning && server != nil` 인메모리 상태만 신뢰. HTTP probe 는 외부 daemon 추적 용도로만. D200H helper 자동 승격 체크는 best-effort 로 유지하되 probe 실패가 재시작 트리거 안 하도록 분리.

**2. Sibling reachability 기반 sessions.json 청소** (`SessionRegistry.swift`, `TimelineRelay.swift`, `DaemonServer.swift`)
`kill(pid, 0)` 만 보던 `listActive()` 는 PID 재사용이나 stuck 프로세스를 못 거름. 새 `listActiveAndReachable()` 는 각 non-daemon 세션의 `/health` 를 1.5s 로 병렬 probe 해서 unreachable 항목을 `sessions.json` 에서 자동 deregister. `TimelineRelay.sync()` 를 async 로 변환, `fetchUsageRelayed`/`refreshSessions` 도 reachable 버전 호출.

**3. `IOHIDManagerOpen` 복구 + 열거 진단** (`D200hHidModule.swift:140-192`)
- `IOHIDManagerOpen(manager, kIOHIDOptionsTypeNone)` 호출 복구 (Seize 안 씀 — keyboard interface 가 macOS input stack 과 공존해야 함).
- 성공 시 `D200H IOHIDManagerOpen succeeded (sandbox=…, usbEntitlement=…)` 로그. 실패 시 `kIOReturnNotPermitted` 와 일반 실패를 구분 로깅.
- 바로 이어서 `IOHIDManagerCopyDevices` 로 한 번 열거해 matched device 수 + D200H 개수 + usage page 를 덤프. 이 **한 줄** 로 "디바이스 안 꽂힘" / "IOKit 보는데 콜백 미발화" / "sandbox 차단" 세 상태를 확정 구분 가능.

**4. iPad Bonjour IPv6 경로** (`BridgeDiscovery.swift`)
- `DiscoveredBridge.wsUrl` — host 에 `:` 있으면 `[...]` 로 감쌈 (IPv4/DNS 는 그대로).
- `handleResults` resolve 필터 확장: `nil`, `169.254.*`, `fe80:*`/`fe80%*`, `::1`, `127.*` 전부 reject + 이유 로그.
- `resolveEndpoint` — `NWParameters.tcp.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options` 에서 `version = .v4` 강제. Bonjour A 레코드만 resolve, AAAA 회피.

**5. WebSocket 접속 가시화** (`WebSocketServer.swift:230`)
`[WS] Client connected (N total)` → `WS: Client connected from 192.168.68.45:53123 (N total)`. 로컬 vs LAN 클라이언트 즉시 구분 (iPad 접속 추적용).

### 핵심 설계 결정
- **In-process 서비스를 자기 HTTP 로 probe 하지 않는다**: "같은 프로세스의 liveness 를 네트워크 경계를 가로질러 확인" 은 data race + cache miss + 자기 부정적 피드백을 만드는 안티패턴. 인메모리 객체 참조로 충분.
- **PID 생존 ≠ 서비스 생존**: `kill(pid,0)` 은 최소 조건. 진짜 liveness 는 프로토콜 레벨 응답이어야 하므로 `SessionRegistry` 에 `listActiveAndReachable()` 을 추가해 sibling-relay 경로에서만 비용을 감당하고 hot path (GUI) 에서는 기존 fast `listActive` 유지.
- **IOKit HID 의 숨은 계약**: `IOHIDManagerScheduleWithRunLoop` + `SetDeviceMatchingMultiple` 만으로 matching 콜백이 발화한다고 오해하기 쉽지만, manager 는 반드시 `IOHIDManagerOpen` 상태여야 이미 꽂힌 device 에 대해 attach callback 이 발화한다. 이건 Apple 문서에 명시가 없어서 인턴 레벨 함정.
- **mDNS 에서 IPv4 강제**: LAN 에서 iPad↔Mac 접속은 IPv4 가 사실상 universal 하고, link-local IPv6 는 zone ID 가 URL/WebSocket 경계를 넘지 못해 고장의 원인만 된다. `NWProtocolIP.Options.version = .v4` 한 줄이 `[fe80::...]` 의 모든 잔혹함을 차단.

### 검증
- `xcodebuild -scheme AgentDeck_macOS` / `AgentDeck_iOS` 양쪽 BUILD SUCCEEDED.
- 런타임: 새 바이너리 실행 직후 `D200H IOHIDManagerOpen succeeded (sandbox=true, usbEntitlement=true)` + `D200H IOKit enumeration returned no devices` → 케이블 재연결 → `D200H Keyboard interface attached` + `D200H Consumer Control interface attached` + `D200H connected via HID` → 첫 `set_buttons` 62KB/61pkt 전송, Gateway/OpenClaw/AgentDeck 버튼 렌더링 확인.
- Daemon 안정성: `Local daemon on port 9120 is no longer healthy — restarting` 메시지 완전 소멸, `Usage Tier 1 OK` 연속 성공, Pixoo pushes 가 매 분 리셋되지 않고 누적.
- iPad: iOS 재빌드 필요, 다음 세션에서 `WS: Client connected from 192.168.x.x:...` 로그로 검증.

---

## 2026-04-12 — macOS Dashboard Display-Sleep Freeze Recovery

### 문제
모니터를 끄고 장시간 자리를 비웠다가 돌아오면 AgentDeck macOS 대시보드가 freeze 된 것처럼 보이고 한참 동안 갱신되지 않았다. Daemon 은 이미 IOKit `kIOMessageSystemHasPoweredOn` 핸들러가 있어 wake 시 Bonjour 재광고/모듈 wake/세션 prune 을 수행 (`DaemonServer.swift:351-382`) 하고 있었지만, 대시보드는 죽은 WebSocket 을 붙들고 있어 비대칭 상태가 만들어졌다.

### 원인 (두 가지가 겹침)
1. **Dashboard 가 wake 사실을 모른다** — `AgentStateHolder.handleForegroundReturn()` 은 SwiftUI `scenePhase` 변화에만 반응한다. macOS 에서 모니터 sleep / 시스템 idle 은 `scenePhase` 를 `.background` 로 떨어뜨리지 않으므로 foreground-return 경로 (`AgentStateHolder.swift:177-228`) 가 영원히 트리거되지 않는다.
2. **WebSocket read timeout 부재** — `BridgeConnection` 의 `URLSessionConfiguration` 에 `timeoutIntervalForResource` 가 설정되지 않아 half-open socket 에서 `ws.receive { … }` 가 무한 대기.
3. 회복은 결국 `staleDataMonitor` (10s tick, 20s threshold) 가 AppNap 해제 후 돌면서 일어났지만, AppNap 해제 → 첫 tick → threshold → waterfall → daemon `/health` → bridge connect 까지 최악 수십 초가 걸려 사용자에게는 freeze 로 보였다.

### 해결
- `apple/AgentDeck/State/AgentStateHolder.swift`
  - Daemon 의 IOKit 패턴을 미러링한 `startSystemWakeListener()` / `stopSystemWakeListener()` / `handleSystemWake()` 추가.
  - Wake 감지 즉시 `connection.forceDisconnectAndRestart()` → `connectTo(preferredLocalBridgeUrl)` 또는 `restartWaterfall()`.
  - Swift IOKit import 에서 `kIOMessageSystemHasPoweredOn` 이 private 이라, DaemonServer 와 동일하게 `0xe0000300` raw 상수를 파일 로컬로 선언 (이름 충돌 피하려고 `_AgentStateHolder` suffix).
  - `init()` 에서 `startStaleDataMonitor()` 옆에 등록, `deinit` 에서 `IOObjectRelease` + `IONotificationPortDestroy` 정리.
- `apple/AgentDeck/Net/BridgeConnection.swift`
  - macOS 한정 `config.timeoutIntervalForResource = 30` 추가. 살아있는 소켓은 `pingIntervalSec` 트래픽으로 갱신되므로 영향 없고, wake 후 dead socket 은 30s 내 receive failure → `handleDisconnect` → reconnect.

### 검증
- 빌드: `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataWakeFix build CODE_SIGNING_ALLOWED=NO` → `BUILD SUCCEEDED`.
- 실기 검증은 다음 세션에서: `pmset sleepnow` / `pmset displaysleepnow` → wake → Console.app 에서
  - `[Lifecycle] system wake — force reconnect`
  - `[BridgeConnection] connecting to ws://127.0.0.1:9120/...`
  - `[BridgeConnection] first message received — connected!`

### 핵심 설계 결정
- macOS 에서 "lifecycle" 신호는 SwiftUI `scenePhase` 만으로는 부족하다. 디스플레이 sleep / 시스템 idle 은 scenePhase 를 흔들지 않으므로, **power management 이벤트는 IOKit 을 직접 들어야 한다**. Daemon 이 이미 하고 있던 것을 대시보드에도 동일 패턴으로 추가해 양쪽 생명주기가 대칭이 되게 만들었다.
- half-open WebSocket 탐지는 ping 타이머 한 축 + URLSession resource timeout 한 축으로 이중화. Ping 타이머가 AppNap 으로 느려지는 상황에서도 resource timeout 이 안전망이 된다.
- AppNap 자체를 `ProcessInfo.beginActivity` 로 끄는 옵션은 보류. 이번 두 가지 수정만으로 증상이 사라지는지 먼저 확인하고, 추가 필요 시 결정한다. Sleep 자체를 막지 않는 `.userInitiated` 만 쓰는 식으로 최소 개입 경로를 남겨둔다.
- Swift IOKit module 에서 `kIOMessageSystemHasPoweredOn` 이 private 으로 올라오는 것은 기존 DaemonServer 에서도 같은 raw 값 상수 우회를 쓰고 있다 (`DaemonServer.swift:10`). 중복 정의를 피하려고 공통 헬퍼를 만들고 싶은 유혹이 있지만, 두 곳만 쓰고 의미가 명확하므로 지금은 그대로 둔다.

---

## 2026-04-11 — D200H 런타임 Refresh + Preview Workflow

### 문제
macOS 앱을 다시 빌드해도 D200H 화면이 바뀌지 않는 것처럼 보였다. 실제 확인 결과 D200H ZIP dump는 Swift 경로에서 계속 생성되고 있었지만, 실행 중인 AgentDeck 번들이 빌드한 산출물과 달라질 수 있었다. 특히 Codex 샌드박스 안에서 `open /tmp/.../AgentDeck.app`를 실행하면 LaunchServices가 번들을 못 보고 `kLSNoExecutableErr`를 반환했지만, 승인된 GUI 실행에서는 정상적으로 열렸다.

### 해결
- `DaemonServer.swift`
  - `POST /d200h/refresh` endpoint 추가. D200H 모듈의 상태 hash를 비우고 다음 `updateDisplay()`가 full `set_buttons`를 다시 보내도록 함.
- `D200hHidModule.swift`
  - `forceFullRefresh(reason:)` 추가.
  - 세션 캐시가 아직 비어 있고 직전 full slot도 없으면 blank `set_buttons`를 보내지 않고 `refreshSkipped: "no_cached_sessions"`로 반환하도록 guard 추가.
- `scripts/d200h-preview-dump.mjs`
  - 최신 `*-set_buttons-*.zip` 또는 지정 ZIP을 추출해 `d200h-contact-sheet.png`, `preview.html`, `manifest.json`을 생성.
- `.agents/workflows/d200h-preview.md`, `package.json`
  - 반복 진단 명령을 `pnpm d200h:preview -- --out /tmp/agentdeck-d200h-preview`로 고정.

### 검증
- 새 guard 빌드:
  - `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HRefreshGuard build CODE_SIGNING_ALLOWED=NO`
  - 결과: `BUILD SUCCEEDED`
- 실행 중인 runtime 확인:
  - `/health` 응답 pid: `44757`
  - 실제 실행 번들: `/Users/puritysb/Library/Developer/Xcode/DerivedData/AgentDeck-dqyrhbwpqboxgiabhllzxkkjxqzy/Build/Products/Debug/AgentDeck.app`
  - `d200h.connected=true`, `sessionsCount=4`, `writeFail=0`
- 강제 refresh:
  - `curl -sv -X POST http://127.0.0.1:9120/d200h/refresh`
  - 결과: `200 OK`, `status=ok`, `sessionsCount=4`, `writeFail=0`
- ZIP preview:
  - `pnpm d200h:preview -- --out /tmp/agentdeck-d200h-preview-final`
  - 대상 dump: `~/.agentdeck/d200h-dumps/20260411-083358-107-set_buttons-L-62661b-GATEWAY_OPENCLAW_AGENTDECK_AGENTDECK.zip`
  - `btn0.png`, `btn1.png`, `btn2.png`, `btn13L.png` 모두 196×196 PNG.
  - contact sheet에서 세션 아이콘/상태 점/텍스트가 top-down 좌표로 정상 배치됨.

### 핵심 판단
- D200H가 안 바뀌는 것처럼 보일 때 먼저 봐야 하는 순서:
  1. `ps`/`/health`의 pid와 app bundle path가 방금 빌드한 산출물인지 확인.
  2. `POST /d200h/refresh`로 full `set_buttons`를 강제 전송.
  3. `pnpm d200h:preview`로 최신 ZIP의 실제 PNG 좌표를 확인.
  4. ZIP preview는 정상인데 실기기만 다르면 그때부터 firmware apply/cache 문제로 보고 Ulanzi SDK/Studio capture를 protocol oracle로만 사용.
- Ulanzi SDK는 AGPL-3.0이고 UlanziStudio host runtime에 묶이므로 AgentDeck 런타임 종속성으로 채택하지 않는다. 현 단계의 근본 해결책은 stock HID ZIP 경로 유지 + dump/preview/force refresh 관측성 강화다.

---

## 2026-04-11 — D200H Swift Button Coordinate + Dump Retention Fix

### 문제
D200H에서 세션 버튼의 아이콘/상태 점이 Stream Deck 기준 위치와 다르게 깨져 보였다. 최신 `partial_update` dump를 추출해 확인한 결과, 텍스트는 `drawText()`에서 top-down 좌표로 뒤집어 렌더링되는데 브랜드 아이콘/상태 점/좌측 인디케이터는 CoreGraphics 기본 좌표계 그대로 그려져 아래쪽으로 뒤집혀 있었다. 그 결과 아이콘이 텍스트 뒤쪽 하단에 겹치고 상태 점도 우상단이 아니라 우하단에 찍혔다.

또한 awaiting/processing animation이 `partial_update` ZIP을 짧은 간격으로 계속 dump하면서, full `set_buttons` dump가 몇 초 만에 prune 되어 실기기 분석 증거가 사라졌다.

### 해결
- `D200hHidModule.swift`
  - `drawInTopDownCoordinates()` 헬퍼를 추가해 버튼 아이콘, 상태 점, 세션 좌측 인디케이터를 텍스트와 같은 top-down 좌표계에서 렌더링하도록 수정.
  - `partial_update` dump를 5초 단위로 throttle.
  - dump prune 정책을 command별로 분리:
    - `set_buttons` 최근 12개 보존
    - `partial_update` 최근 24개 보존

### 검증
- 최신 dump에서 문제 재현:
  - `/tmp/d200h-latest.zip`
  - `icons/btn1.png`가 196×196 PNG이며, 아이콘/상태 점이 하단에 뒤집혀 보임.
- Xcode 빌드 시도:
  - `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HVisualFix build CODE_SIGNING_ALLOWED=NO`
  - 실패 원인은 이 변경과 무관한 기존 local worktree 상태: `apple/AgentDeck/Terrarium/Creatures/JellyfishCreature.swift`가 삭제되어 있는데 Xcode project와 `TerrariumRenderer.swift`는 아직 `JellyfishCreature`를 참조.

### 핵심 판단
- 이번 깨짐은 D200H stock firmware 문제가 아니라 Swift CoreGraphics 렌더러 내부 좌표계 불일치다.
- 장기적으로는 Swift에서 Stream Deck SVG를 손으로 재구현하지 말고, shared SVG renderer → deterministic rasterizer 경로를 D200H도 사용해야 한다. 단 Node `@resvg/resvg-js` 경로는 `loadSystemFonts: false`일 때 텍스트가 누락되는 것을 확인했으므로, font bundling 또는 system font loading 정책까지 같이 정해야 한다.

---

## 2026-04-11 — Terrarium 크리처/태그 렌더링 버그 일괄 수정

### 문제
태블릿/CremaS/Pantone에서 다수 시각 이슈:
1. **CremaS에 OpenCode 안 보임** — 배경과 거의 동일 회색.
2. **e-ink Codex 크리처 이상** — 거대 oval → 원 겹치기 → PathParser arc 미지원 fallback → 그냥 원.
3. **태블릿 세션명 태그가 크리처와 과도하게 떨어져 있음** — homeY → STANDING_Y 이동 후에도 태그 위치 고정.
4. **Claude Code 태그가 크리처 오른쪽으로 치우침** — 태그는 `cx`, body는 `cx - (bodyRadius - 12)` 에 렌더링.
5. **크리처들 지면 위에서 떠있음** — STANDING_Y 너무 높음 (0.60~0.62).
6. **macOS 대시보드에 sibling 모델명 미표시** — `modelName: nil` 하드코딩.

### 원인

**Issue 1 — OpenCode 색상 대비**: `GRAY_OPENCODE_OUTER = 0xBBBBBB` (level 11) vs `GRAY_WATER_BG = 0xDDDDDD` (level 13). 16-level 양자화에서 2레벨 차이 = 사실상 투명.

**Issue 2 — SVG 렌더링 경로 실패**: `androidx.core.graphics.PathParser.createPathFromPathData()`는 SVG arc 명령(`a rx ry rot large sweep dx dy`)에서 flag 값이 분리되지 않은 형식(`012.285`)을 파싱 못 함. 예외 발생하지만 try/catch에서 조용히 원으로 fallback. hand-traced cubic bezier도 undulation 없이 매끄러워서 원이 됨.

**Issue 3 — 이름태그 캐시 버그**: `CachedNameLayout`에 `tagBottomY`를 저장. 캐시 키는 `(name, bodyMetric)`만 포함 → 크리처가 `homeY` (layout slot, ~0.42)에서 `STANDING_Y` (0.635)로 lerp 이동해도 태그는 초기 위치에 고정. 200+px 갭.

**Issue 4 — SVG withTransform pivot 오류**: OctopusCreature.drawSvgBody에서
```kotlin
translate(offsetX = cx - 12*svgScale, ...)
scale(svgScale, pivot = Offset(12, 12))
```
Compose의 `scale(sx, sy, pivot)`은 pivot을 현재 translated 좌표공간에서 해석. 결과: SVG center (12,12)가 screen (12 + cx - 12*svgScale) = `cx + 12 - bodyRadius`에 매핑 → body가 (bodyRadius - 12)px 만큼 왼쪽으로 밀림. 태그는 `cx`에 있으므로 상대적으로 오른쪽 치우침.

**Issue 5 — STANDING_Y**: 0.60/0.61/0.62 → 모래 (0.65) 위 3~5% 공중.

**Issue 6 — modelName 누락**: 
- Swift `TerrariumState.swift`가 sibling에 `modelName: nil` 하드코딩.
- Android `SessionInfo` 데이터 클래스에 `modelName` 필드 자체가 없음 → daemon broadcast 파싱 시 탈락.

### 해결

1. **OpenCode 대비**: `GRAY_OPENCODE_OUTER` 0xBB → 0x88 (level 8, 5레벨 차이). Outline stroke 추가로 엣지 보강.
2. **Codex e-ink**: 태블릿 `CloudCreature` 의 `LOBE_OFFSETS`/`LOBE_RADII` 그대로 이식 (6-lobe clover). `bodyRadius = w * 0.055f` 로 충분히 커서 lobe들이 확실히 겹침. `drawCircle` 6개 + 중앙 seal.
3. **태그 캐시**: `tagBottomY`를 캐시에서 제거, 매 프레임 `bodyTopY - bodyMetric * GAP_RATIO`로 live 계산. `CreatureNameTagStyle` import 필요.
4. **SVG transform**: 3단 변환으로 재작성:
   ```kotlin
   translate(cx, cy)
   scale(effScale, pivot = Offset.Zero)
   translate(-SVG_VIEWBOX/2, -SVG_VIEWBOX/2)
   ```
   이렇게 하면 SVG (12,12)가 정확히 (cx,cy)에 매핑. `scale`에 `pivot = Offset.Zero` 명시 중요 — 기본 pivot은 canvas center라 생략 시 creature가 화면 밖으로 날아감.
5. **STANDING_Y**: 0.635f 로 통일 + `coerceAtMost(0.65f)` clamp (jitter+homeX offset으로 모래 속으로 파고드는 것 방지). E-ink `restY`도 0.64f로 올림.
6. **modelName 전파**:
   - Android `SessionInfo` data class에 `modelName: String?` 필드 추가
   - `EinkAgentColumn` / `SessionListPanel`에서 `session.modelName` 사용 (null 하드코딩 제거)
   - Swift `TerrariumState.swift`에서 `sibling.modelName` 전달 + `JellyfishCreatureState`/`OpenCodeCreatureState`에 `modelName` 필드 추가

### 핵심 설계 결정

- **태그 캐시 정책**: 텍스트 레이아웃(lines, fontSize, dimensions)만 캐시, 위치(tagBottomY)는 절대 캐시 금지. 크리처가 lerp 이동하는 동안 위치는 매 프레임 바뀌므로.
- **Compose `withTransform` scale pivot**: 기본값이 canvas center라는 점을 잊지 말 것. SVG 렌더링할 때 반드시 `pivot = Offset.Zero` 명시하거나 translate → scale → translate 3단 변환 사용.
- **E-ink 색상 대비 규칙**: B&W 16-level에서 인접 요소간 최소 4-5레벨 차이 필요. 2레벨 이하는 거의 안 보임.
- **Codex 실루엣 렌더링**: e-ink에서 SVG path 외곽만으로는 blob/원이 됨 (원본 codex.svg의 식별 가능 모양은 evenodd fill의 `>_` cutout 덕분). 6-lobe 원 겹치기가 e-ink에서 더 distinctive.

### 검증 방법
- 3기기 스크린샷 캡처 후 각 크리처+태그 영역 크롭하여 pixel-level 검증
- `adb -s <device> exec-out screencap -p` + Python PIL crop
- 크리처별 위치, 태그-크리처 정렬, 크기 시각 확인

---

## 2026-04-11 — 세션 목록 정렬 안정화 + D200H 정합성 개선

### 문제
여러 surface에서 세션 목록이 불안정했음:
1. **상태 변화마다 세션 순서가 뒤바뀜**: SD+, D200H, macOS/iOS dashboard, Android 전반에서 세션이 `processing→idle` 같은 상태 전환 시 목록 위치가 점프.
2. **D200H 버튼 눌러도 옵션 선택이 동작 안 함**: `{type: "respond", response: key}` 페이로드 전송했지만 bridge PTY adapter는 `cmd.value`를 읽음 → `undefined`로 무시됨.
3. **D200H 세션 타일 크리처가 너무 아래로 쳐짐**: SD+ 144px 레이아웃이 196px로 스케일 안 된 채 고정 좌표 사용.
4. **옵션 선택 모드 slot 1이 텍스트 위주**: 세션 리스트 타일과 달리 크리처 + 모델 + 상태 표시가 빠짐.
5. **Usage 머지 버튼 아이콘/텍스트 겹침**: 아이콘이 중앙, 텍스트 오버레이도 중앙 근처.

### 원인
**정렬 불안정**: `shared/src/session-utils.ts`의 `sortSessions()`가 1차 키로 `stateRank()` (processing=0→awaiting=1→idle=2) 사용. 상태는 실시간으로 바뀌므로 정렬 키 자체가 불안정.

**respond 필드명 불일치**: Swift D200hHidModule이 프로토콜 정의(`ResponseCommand.value`)와 다르게 `response` 필드 사용.

**레이아웃 픽셀 오프셋**: SD+(144px) 좌표를 D200H(196px)로 수동 이식하면서 1.36배 스케일 계산이 일부 요소에서 빠짐.

### 해결

**1. 안정 정렬 (단일 source of truth)**
`shared/src/session-utils.ts`에서 `sortSessions()` 재작성:
```
1. agentTypeRank: openclaw(0) → claude-code(1) → codex-cli(2) → opencode(3)
2. projectName alphabetical
3. startedAt ascending (oldest first)
4. session id tiebreaker
```
`stateRank`는 display용으로만 남김. 정렬에서 제거.

동일 로직을 **`agentTypeRank` 이름의 함수로 미러링**:
- `plugin/src/session-slot-manager.ts`: OpenClaw 수동 prepend 제거, canonical sort에 위임
- `apple/.../UI/Monitor/SessionListPanel.swift`: `Self.agentTypeRank()` 추가
- `android/.../ui/eink/EinkFormatUtils.kt`: `fun agentTypeRank()` 추가
- `android/.../ui/eink/EinkAgentColumn.kt`, `SessionListPanel.kt`: `sortedWith(compareBy { agentTypeRank(...) })`

**2. respond 필드명 수정**
`D200hHidModule.swift`: `"response"` → `"value"`. 한 줄 수정이지만 옵션 선택 전체 플로우가 이것 때문에 먹통이었음.

**3. 세션 타일 레이아웃 상향**
`drawSessionTextOverlay()`: projectName y=108→96, model y=132→118, state y=158→146.
`renderButtonPng()` badge rect: y=18→14, height=78→74.
SD+ 144px 레이아웃의 정확한 1.36배 스케일.

**4. Option select slot 1 풀 타일 오버레이**
`computeOptionSelectSlots()`: slot 1에 `textOverlay: .sessionTile`, `modelName`, `statusColor`, `stateLabel` 전달. 세션 리스트 타일과 동일한 크리처 + 모델 + 상태 렌더링.

**5. Usage 버튼 아이콘/텍스트 분리**
`renderButtonPng()`: `slot.textOverlay == .usageStat`인 경우 iconRect를 상단(`y=18, height=52`)으로 이동.
`drawUsageTextOverlay()`: label y=100, percent y=130 (22pt로 키움), reset y=158. 수직 4단 분리.

### 핵심 설계 결정

**정렬 키에서 상태 제거**: "활성 세션을 위로" 직관적이지만 실제로는 클릭하려는 순간 세션이 다른 위치로 점프해 실수를 유발. 안정성 > 관련도 우선순위.

**agentTypeRank 하드코딩**: 4개 타입, 순서 불변이므로 하드코딩 적절. 설정화는 over-engineering.

**respond 필드명 버그의 교훈**: Swift D200H 모듈이 직접 프로토콜 타입 의존성을 갖지 않는 구조에서 발생. 장기적으로는 `generate-protocol` 스크립트가 Swift 측에도 typed command builder를 생성해야 함 (향후 작업).

**SD+ 좌표 수작업 포팅의 한계**: 현재 D200H 렌더러가 SD+ SVG를 직접 rasterize하지 않고 CoreGraphics로 재구현. 1.36x 스케일 팩터를 수동으로 기억해야 하는 부담. 향후 Swift에도 SVG 렌더러(SVGKit 등) 도입하면 해결 가능.

---

## 2026-04-11 — macOS Daemon Self-Detection Race (D200H Helper Promotion)

### 문제
macOS 앱 실행 시 데몬이 포트 9120에 정상 바인드된 직후 health endpoint가 응답 불능 상태로 빠지고, 이후 "External daemon disappeared → 재시작 → 또 실패" 루프에 빠짐.

로그 패턴:
```
Server listening on port 9120
... (정상 가동)
Promoting D200H to bundled helper: ... lacks usable D200H USB entitlement
External daemon detected on port 9120 — connecting as client   ← 자기 자신
HTTP load failed ... -1001 (timeout)
External daemon on port 9120 disappeared — promoting this app to own the daemon
```

### 원인
`DaemonService.startBundledD200HHelper()`가 target port를 probe해서 `mode: "daemon"` 응답이 오면 early-return으로 `connectToExternalDaemon()`을 호출. 하지만 sandbox 빌드에서 D200H USB 권한이 없을 때 health monitor가 이 함수를 호출하면, probe가 **자기 자신의 로컬 데몬**에 도달해 self-detection이 발생. `connectToExternalDaemon()`이 `self.server = nil`로 로컬 DaemonServer를 orphan시키고 HTTP 응답이 끊김.

### 해결
`/health` 엔드포인트가 이미 `ProcessInfo.processInfo.processIdentifier`를 `pid` 필드로 반환 (`DaemonServer.swift:549`). 이를 자신의 PID와 비교하여 self-detection을 구분:

```swift
let remotePid = health["pid"] as? Int
let myPid = Int(ProcessInfo.processInfo.processIdentifier)
if remotePid != myPid {
    await connectToExternalDaemon(port: targetPort)
    return
}
// self-detection → 기존 fall-through: stop() + spawn bundled helper
```

부수적으로 `promotionTargetPort` / `resolvedSessionOverridePort` / `syncResolvedPortState` 헬퍼로 port resolution 로직을 재사용 가능하게 리팩터.

### 핵심 설계 결정
- **PID는 in-process daemon self-detection의 authoritative signal.** 로컬 Swift 데몬과 외부 Node helper는 항상 다른 PID를 가지므로 `/health.pid == ProcessInfo.processIdentifier`는 정확한 self-check.
- **Self-detect 시 fall-through가 의도**: 사용자가 `autoUseBundledD200HHelper=true`로 설정했다면 sandbox 데몬을 bundled helper로 교체하는 것이 본래 목적. 단 local daemon을 `connectToExternalDaemon()`으로 orphan하는 것만 금지.
- **잔여 이슈**: `stop()` 직후 helper가 동일 포트를 bind하려다 TIME_WAIT에 막히는 별도 문제가 있음 — helper 측 TIME_WAIT 재시도 로직 필요 (다음 세션).

---

## 2026-04-08 — D200H Text Bake Experiment: Session-Mode ShowTitle Toggle + PNG Text Overlay

### 문제
Stream Deck와 D200H 사이의 가장 큰 시각 차이는 여전히 텍스트 레이어였다.

- Stream Deck는 세션 버튼 하나의 캔버스 안에 `project / model / state`를 모두 그린다.
- D200H Swift 경로는 펌웨어 안전성을 우선해 PNG는 icon-only로 유지하고, 텍스트는 native label에 맡기고 있었다.

이 구조는 안정적이지만, 버튼 레이아웃이 Stream Deck와 다르게 느껴지는 핵심 원인이었다.

### 해결
- `apple/AgentDeck/App/AppPreferences.swift`
  - `d200hBakeSessionText`
  - `d200hHideNativeSessionLabels`
  - 두 실험 설정을 추가하고 기본값을 `true`로 둬, 현재 사용 환경에서는 바로 Stream Deck parity 시도를 하도록 함
- `apple/AgentDeck/UI/Settings/SettingsScreen.swift`
  - D200H Helper 섹션에 위 두 옵션을 노출
  - 세션 PNG에 텍스트를 굽는 실험과 session-mode `ShowTitle: 0` 동작을 앱에서 바로 제어 가능하게 함
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`
  - label style packet을 고정 `ShowTitle: 1`에서 벗어나 **현재 모드별 동적 제어**로 변경
    - `sessionList` + 실험 on: `ShowTitle: 0`
    - `optionSelect`: `ShowTitle: 1`
  - 따라서 세션 그리드에서는 native label을 숨기고, 옵션 화면에서는 다시 켜서 기존 조작성을 유지
  - session tile에 `textOverlay: .sessionTile`을 추가해 PNG 안에:
    - project name
    - model name
    - `● STATE`
    를 직접 렌더
  - usage merged button도 session mode에서 native label을 끄는 경우 숫자를 잃지 않도록 `.usageStat` overlay를 추가
  - full-render 캐시 키를 `title`만 보던 구조에서 `model/state/overlay/border`까지 포함하도록 넓혀, text-bake 경로에서 상태 변화가 누락되지 않게 함

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HTextBake build CODE_SIGNING_ALLOWED=NO`
  - 최종 `BUILD SUCCEEDED`

### 핵심 판단
- 이제 Swift 앱은 **세션 모드에 한해** Stream Deck와 훨씬 더 유사한 “single-canvas session button” 실험을 직접 수행할 수 있다.
- 다만 이건 아직 **firmware acceptance 실험 단계**다.
  - 코드상/빌드상으로는 성립했지만
  - 실제 D200H stock firmware가 이 richer PNG를 안정적으로 계속 받아줄지는 실기기 확인이 필요하다
- 만약 이 경로가 실기기에서 안정적으로 먹히면, `완전 동일 UI`에 가장 가까운 stock-firmware 경로가 열린다.
- 반대로 여기서 화면 복귀/무시가 다시 나타나면, 그때는 vendor payload semantics를 더 맞추거나 takeover로 넘어갈 근거가 훨씬 선명해진다.

---

## 2026-04-08 — D200H Stream Deck Parity Pass: Press Flash + Awaiting Glow

### 문제
D200H Swift 렌더는 브랜드 로고와 상태색을 Stream Deck 쪽에 가깝게 맞춘 뒤에도, 체감상 두 가지 큰 차이가 남아 있었다.

1. 버튼을 눌렀을 때 `showOk`/`showAlert`에 준하는 즉시 피드백이 없어, ZIP 재렌더 전까지 눌림 확인이 비었다.
2. `awaiting` 보더가 단순 alpha pulse라서 Stream Deck SVG의 gaussian glow보다 훨씬 딱딱하게 보였다.

이 차이는 펌웨어-safe icon-first 경로를 유지하더라도 바로 줄일 수 있는 영역이었다.

### 해결
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`
  - 버튼 입력 처리에 `press flash` 단계 추가:
    - 눌린 버튼을 즉시 밝게 만든 `PARTIAL_UPDATE`를 먼저 전송
    - 약 `90ms` 후 실제 command resolution / local handling 수행
    - 화면 전환이 없는 버튼은 현재 상태 partial ZIP으로 자동 복원
  - flash는 단순 배경 변경이 아니라:
    - 배경 밝기 상승
    - 아이콘 밝기 상승
    - 보더를 밝은 solid highlight로 승격
  - option 모드의 merged `BACK` 버튼(slot 13)도 partial ZIP으로 flash 가능하도록 `renderPartialZip()`이 merged button partial을 지원
  - 렌더 경로 중복을 줄이기 위해 현재 화면 상태 계산을 `currentDisplayRenderState()`로 분리
  - `awaitingPulse` 렌더에 CoreGraphics shadow blur를 추가해 Stream Deck의 glow border 감각에 더 가깝게 조정

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HParity build CODE_SIGNING_ALLOWED=NO`
  - 최종 `BUILD SUCCEEDED`

### 핵심 판단
- 이 변경으로 D200H는 단순히 “비슷한 정적 타일”이 아니라, `눌림 반응`과 `awaiting animation`까지 Stream Deck에 더 가까운 촉감을 갖게 됐다.
- 다만 **완전 동일 UI는 아직 아니다.**
  - 현재 경로는 여전히 stock firmware safe path를 우선해 PNG 내부 텍스트를 비워 두고 native label을 사용한다.
  - 따라서 Stream Deck의 `project / model / state` 3단 텍스트를 같은 캔버스에 완전히 재현하려면 vendor-accepted richer PNG 조합을 더 찾거나, stock firmware 우회가 필요하다.

---

## 2026-04-08 — D200H Session Semantics Pass: Gateway Tile, Back Merge, Stock-like Actions

### 문제
현재 Swift D200H 경로는 화면 출력 자체는 살아 있었지만, 세 가지 구조 문제가 남아 있었다.

1. 세션 버튼을 눌러 실제로는 mode 전환/명령 라우팅이 일어나도 로그에는 `pressed (unmapped)`가 같이 찍혀 디버깅을 오염시켰다.
2. `sessions_list`에 주입된 virtual OpenClaw session(`openclaw-gateway`)을 일반 sibling session처럼 `focus_session`으로 처리해 `Session openclaw-gateway not found`가 남았다.
3. option 모드에서 입력상 slot 13은 `BACK`인데, 렌더는 여전히 usage merged button 경로를 타고 있어 시각/입력이 어긋날 수 있었다.

부가적으로 `sessions_list`는 sibling health에서 `currentTool/options/navigable`를 버리고 있었고, D200H manifest도 `Action/ActionParam` 없이 `Text + Icon`만 보내고 있었다.

### 해결
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`
  - 버튼 resolution을 `command / handled / unmapped`로 분리해, 내부적으로 처리된 버튼이 더 이상 `unmapped`로 로그되지 않게 함.
  - `openclaw-gateway`는 virtual gateway session으로 취급:
    - D200H UI에서는 project 이름을 `Gateway`로 정규화
    - session 버튼을 눌러도 `focus_session`을 보내지 않고 local option-select만 진입
  - full ZIP renderer를 mode-aware로 변경:
    - session list 모드에서는 기존 usage merged button 유지
    - option 모드에서는 slot 13 merged button을 실제 `BACK` 시각으로 렌더
  - manifest builder에 stock-like `Action` + `ActionParam.Path`를 추가:
    - session tiles: `agentdeck://session/<id>`
    - option controls: `agentdeck://back`, `agentdeck://option/...`, `agentdeck://interrupt`, `agentdeck://escape`, `agentdeck://more`
    - usage merged slots는 계속 `Action: ""`로 clock widget 캐시를 지움
  - native label은 `title` 중심으로 더 보수적으로 사용해 icon-first 성향을 유지
- `apple/AgentDeck/Daemon/Server/DaemonServer.swift`
  - sibling `/health` probe 결과에서 `currentTool`, `options`, `navigable`까지 세션에 보존
  - virtual OpenClaw session에도 동일 필드를 넣어 D200H option view가 gateway 상태를 더 잘 반영하도록 함
  - `sessionToDict()`도 위 필드를 `sessions_list`에 포함
- `apple/AgentDeck/Daemon/Session/SessionRegistry.swift`
  - enriched session 필드에 `currentTool`, `options`, `navigable` 추가

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HReview build CODE_SIGNING_ALLOWED=NO`
  - 최종 `BUILD SUCCEEDED`

### 핵심 판단
- 이 수정으로 D200H는 단순히 “보이기만 하는” 단계를 넘어, session semantics도 Stream Deck 쪽과 더 비슷한 구조를 갖게 됐다.
- 아직 vendor protocol cloning이 끝난 것은 아니지만, manifest도 이제 `icon + action semantics`를 함께 보내기 시작했기 때문에 stock firmware 쪽 accepted shape에 더 가까워졌다.

---

## 2026-04-08 — D200H Protocol Cloning Gate: Stock Icon Evidence + Compare Tooling

### 문제
사용자는 D200H를 단순 safe-path 아이콘 기기가 아니라, 가능한 한 Stream Deck 수준의 시각 밀도와 동작으로 끌어올리길 원했다. 이 시점에서 판단해야 할 것은 두 가지였다.

1. stock firmware 위에서 vendor protocol을 복제하는 경로가 아직 유효한가
2. 아니면 device takeover를 주 경로로 승격해야 하는가

하지만 오늘 작업 환경에는 `UlanziStudio.app`가 설치되어 있지 않아 live `IOHIDDeviceSetReport` 캡처를 바로 뜰 수 없었다.

### 해결
- `zkswe/recon/build-ulanzi-hid-capture.sh`로 vendor HID interpose capture 도구를 다시 빌드해, live capture 준비 상태는 유지했다.
- `zkswe/recon/d200h_zip_tool.py`를 보강했다:
  - `profile` 서브커맨드 추가
  - ZIP뿐 아니라 `manifest0_json.txt` 같은 manifest dump도 직접 분석 가능
  - `Action`, `State`, `ViewParam.Text`, `ViewParam.Icon` 분포와 PNG 크기 통계를 바로 출력
  - `compare`는 이제 ZIP과 manifest source를 섞어 비교 가능
- 기존 stock dump를 기준으로 stock firmware의 허용 범위를 수치로 고정했다:
  - `zkswe/recon/dumps/20260327_214855/manifest0_json.txt`
  - `zkswe/recon/dumps/20260327_214855/res_listing.txt`
- 분석 결과:
  - stock `manifest0.json`: 버튼 19개, `Text` 비어 있음 19개, `system.open` 액션 18개, `smallwindow.window` 1개
  - stock 기본 icon: 14종, 참조된 PNG 크기 `29255`~`35837` bytes, 평균 `32154.8` bytes
  - 즉 stock firmware는 "텍스트를 거의 쓰지 않는 icon-first 버튼"을 기본값으로 삼고 있고, rich PNG도 충분히 싣고 있다
- 우리 최신 AgentDeck D200H dump는 여전히 작은 PNG + label fallback 비중이 높다:
  - partial update 기준 `text='OpenClaw'`, `icon='icons/btn0.png'`, PNG 약 `4.6KB`
  - stock의 기본 icon 밀도와는 아직 차이가 크다

### 검증
- `bash zkswe/recon/build-ulanzi-hid-capture.sh` 성공
- `python3 zkswe/recon/d200h_zip_tool.py profile zkswe/recon/dumps/20260327_214855/manifest0_json.txt --res-listing zkswe/recon/dumps/20260327_214855/res_listing.txt`
  - stock manifest/action/text/icon 분포 출력 확인
  - stock icon 파일 크기 통계 확인
- `python3 zkswe/recon/d200h_zip_tool.py compare zkswe/recon/dumps/20260327_214855/manifest0_json.txt <latest AgentDeck partial_update zip>`
  - stock은 `Action`/`ActionParam`/icon-only 구조
  - AgentDeck은 `Action=<none>` + `Text='OpenClaw'` 경향
- `python3 -m py_compile zkswe/recon/d200h_zip_tool.py` 성공

### 핵심 결론
- **protocol cloning은 아직 죽지 않았다. 오히려 주 경로로 유지해야 한다.**
  - stock firmware 자체가 rich icon을 충분히 사용한다는 증거가 나왔다.
  - 따라서 현재 한계는 "D200H 패널의 본질적 한계"보다 "우리가 아직 vendor-accepted payload semantics를 정확히 못 맞춘 상태"에 가깝다.
- **takeover는 계속 연구 트랙으로 유지한다.**
  - MI_GFX visible target, boot timing, ADB 안정성, 14-key 입력 매핑까지 모두 제품화 수준으로 정리되지 않았다.
  - takeover는 자유도 상한은 높지만, 당장 Stream Deck 수준 제품을 만드는 가장 빠른 길은 아니다.

### takeover 승격 조건
- 다음 조건을 충족해도 stock firmware가 richer button image를 계속 무시하면 takeover를 주 경로로 승격한다:
  1. vendor payload live capture 확보
  2. manifest semantics를 stock 쪽에 더 가깝게 정렬
     - icon-first
     - `Text` 최소화
     - `Action` / `ActionParam` 채움
  3. richer PNG 크기와 ZIP 구조를 vendor/stock 허용 범위에 맞춤
- 반대로 위 정렬만으로 full-image button acceptance가 살아나면, takeover는 백업 경로로 남긴다.

---

## 2026-04-08 — D200H Stream Deck Brand Renderer Port

### 문제
D200H Swift 경로는 펌웨어 안전성을 위해 PNG에서 텍스트를 거의 제거한 뒤 너무 단순한 기하 아이콘만 남겨, Stream Deck 구현과 같은 시각 언어가 사라져 있었다. 특히 세션 버튼이 상태색으로만 칠해진 러프한 심볼에 머물러 실제 Stream Deck 세션 슬롯의 브랜드 로고/배지 인상을 재현하지 못했다.

### 해결
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`에서 세션 로고 색을 상태색이 아니라 **에이전트 브랜드색**으로 분리:
  - Claude Code `#C07058`
  - OpenClaw `#ff4d4d`
  - Codex CLI `#6366f1`
  - OpenCode `#F1ECEC`
- D200H PNG 렌더에 Stream Deck renderer 기준 브랜드 path를 직접 이식:
  - Claude robot
  - Codex knot/clover
  - OpenCode nested-square
  - OpenClaw body/claws/antennae/eyes
- `CGPath`용 SVG path parser를 추가해 TypeScript `agent-logos.ts`와 같은 path 데이터를 Swift PNG 렌더에서도 재사용 가능하게 함.
- 세션 타일 상단에 **brand badge**를 추가하고, 그 안에 실제 로고를 배치해 Stream Deck 세션 슬롯의 상단 시그니처 구조를 모사.
- 빠른 액션 아이콘도 Stream Deck 구현에 맞춰 정리:
  - `GO ON` 삼각형
  - `REVIEW` 문서 라인 아이콘
  - `COMMIT` 원형 + 체크
  - `CLEAR` X
- 텍스트는 여전히 `manifest ViewParam.Text`에 남겨 PNG를 텍스트-free로 유지했다. 즉 미감을 올리되, 과거처럼 텍스트가 들어간 PNG 때문에 `SET_BUTTONS`가 거부되는 경로로는 되돌리지 않았다.

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -derivedDataPath /tmp/AgentDeckDerivedDataD200HBoundaryFix build CODE_SIGNING_ALLOWED=NO` 성공.
- 새 Swift 앱 런타임 dump 확인:
  - `20260408-133756-150-set_buttons-L-37674b-OPENCLAW_OPENCLAW__.zip`
  - 전체 ZIP `37674 bytes / 37 packets`
  - `icons/btn0.png = 3649 bytes`
  - `icons/btn1.png = 6257 bytes`
  - `icons/btn13L.png = 2754 bytes`
- 실제 추출 PNG 육안 확인 결과:
  - 기존의 단순 사각/타원 심볼 대신 브랜드 로고와 배지가 보임
  - usage 버튼도 기존과 같은 safe path를 유지

### 남은 한계
- 현재 stock firmware safe path에서는 **Stream Deck의 3단 텍스트 레이어(project/model/state)를 PNG 내부에 그대로 넣는 방식**까지는 복원하지 않았다.
- 따라서 지금은 “브랜드/배경/상태 장식은 Stream Deck에 상당히 근접”, “텍스트 구조는 D200H native label 제약 안에서 동작” 상태다.
- 여기서 더 나아가 완전히 동일한 버튼 캔버스를 원하면 다음 둘 중 하나가 필요하다:
  - Ulanzi 전송 규약을 더 정확히 역공학해서 richer PNG/manifest 조합을 재현
  - stock firmware를 우회하고 자체 렌더러로 takeover

---

## 2026-04-07 — macOS AgentDeck.app D200H bundled helper 승격

### 문제
Swift `AgentDeck.app` 안의 샌드박스 daemon은 D200H HID open에서 `kIOReturnNotPermitted`가 발생해 기기가 기본 펌웨어 화면으로 복귀했다. 사용자는 CLI를 수동 실행하거나 UlanziStudio/공식 SDK에 의존하지 않고, **앱 하나로 D200H를 정상 운용**하길 원했다.

### 해결
- `apple/scripts/copy-adb.sh`에서 앱 번들 `Contents/Helpers/`에 D200H helper runtime을 함께 복사:
  - `node`
  - `agentdeck-d200h-helper`
  - `agentdeck-runtime/bridge/dist`
  - 필요한 `node_modules`
- `DaemonService`에 `startBundledD200HHelper()` 추가. 앱이 로컬 daemon을 내리고 번들 helper를 직접 띄운 뒤 `/health`가 올라오면 해당 daemon에 클라이언트로 재연결한다.
- D200H 상태 헬스에 `sandboxEnabled`, `usbEntitlementPresent`, `lastOpenError`를 포함시켜 Swift 쪽이 권한 실패를 명시적으로 감지하도록 했다.
- 로컬 Swift daemon health check에서 `USB entitlement 없음` 또는 `kIOReturnNotPermitted`가 보이면 사용자가 CLI를 만지지 않아도 **앱이 자동으로 번들 D200H helper로 승격**되게 했다.
- Settings에 `Auto-switch D200H to bundled helper` 토글과 수동 강제 전환 버튼 추가.
- D200H 역공학용 도구 추가:
  - Swift daemon이 실제로 만든 `SET_BUTTONS` / `PARTIAL_UPDATE` ZIP을 `~/.agentdeck/d200h-dumps/`에 dedupe dump
  - `zkswe/recon/ulanzi_hid_capture.c` + `build-ulanzi-hid-capture.sh`로 macOS `IOHIDDeviceSetReport` interpose 캡처
  - `zkswe/recon/d200h_zip_tool.py`로 raw packet → ZIP 재구성 및 ZIP/manifest 비교
- Swift/Node ZIP builder 결함 수정:
  - 기존 dummy-file padding 방식은 앞쪽 PNG data에 걸린 invalid boundary byte를 절대 고칠 수 없었음
  - dump 분석 결과 실제로 `16376`, `23544`, `30712` 같은 boundary offset에 `0x00`가 반복적으로 남아 있었음
  - 해결: ZIP local header extra field padding을 엔트리별로 조정하는 방식으로 전환
  - 검증: 새 dump `20260406-161556-747-set_buttons-L-45019b-OPENCLAW_OPENCLAW__.json` 기준 invalid boundary byte `bad=0`

### 핵심 설계 결정
- **"CLI fallback"이 아니라 "app-owned helper"**: 사용자는 `AgentDeck.app`만 실행한다. helper는 앱 번들 내부 자산으로 배포·기동되고, 제어권도 AgentDeck에 남긴다.
- **Ulanzi SDK/Studio 비채택 유지**: 공식 SDK는 UlanziStudio 플러그인 모델이라 호스트 제어권이 Ulanzi 앱에 묶인다. 우리의 목적은 AgentDeck이 직접 D200H 상태/렌더/입력을 소유하는 구조다.
- **자동 승격 조건은 D200H 권한 실패 신호에 한정**: 단순 연결 지연이 아니라 `sandbox + no USB entitlement` 또는 `open denied`일 때만 helper로 넘어가 불필요한 전환을 막는다.
- **공식 앱은 runtime dependency가 아니라 protocol oracle**: UlanziStudio를 제품 경로로 쓰지 않고, 필요할 때 `IOHIDDeviceSetReport` 캡처 대상으로만 활용해 vendor ZIP/manifest 규약을 복제한다.

---

## 2026-04-08 — D200H Swift 렌더 경량화 + ZIP 스펙 정합성 + Consumer 입력 fallback

### 문제
Swift D200H 경로를 다시 실기 로그 기준으로 점검한 결과, 단순 HID open 실패만이 아니라 payload 자체에도 문제가 있었다.

1. 버튼 텍스트를 PNG에 직접 굽는 경로가 다시 들어와 ZIP/PNG 크기가 커졌다.
2. boundary padding을 위해 ZIP local/central header extra 영역에 raw `0x41` filler를 바로 넣어, Python `zipfile`이 손상으로 판정하는 비정상 extra field를 만들고 있었다.
3. 현재 macOS 앱 빌드에서는 Keyboard HID 인터페이스가 `kIOReturnNotPermitted`로 막히지만 Consumer 인터페이스는 열리는 경우가 있어, 화면은 갱신돼도 입력이 죽을 수 있었다.

### 해결
- Swift `D200hHidModule.swift` 렌더 경량화:
  - 일반 버튼 PNG를 `icon-only`로 축소
  - 버튼 라벨은 manifest `ViewParam.Text`로 다시 이동
  - usage merged slot도 커스텀 gauge/text PNG 대신 icon-only half PNG + native text(`5H xx%`, `7D yy%`)로 단순화
- ZIP extra field 정합성 수정:
  - boundary shift는 계속 local header extra field padding으로 수행
  - 단, extra bytes를 raw filler가 아니라 `header(0x4141) + length + payload` 형식의 유효한 ZIP extra field로 생성
  - 같은 수정 적용: Swift `D200hHidModule.swift` + Node `bridge/src/d200h/image-renderer.ts`
- Consumer input fallback:
  - Swift 구현도 Node와 동일하게 Consumer Control 인터페이스에 input callback을 등록
  - Keyboard interface open이 막혀도 button report가 Consumer 쪽으로 들어오면 입력을 받을 수 있게 준비

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HZipSpec build CODE_SIGNING_ALLOWED=NO`
- `pnpm --filter @agentdeck/bridge build`
- 새 Swift dump 기준:
  - `~/.agentdeck/d200h-dumps/20260408-132027-552-set_buttons-L-33219b-OPENCLAW_OPENCLAW__.zip`
  - boundary invalid byte `bad=0`
  - Python `zipfile.ZipFile(...)`로 정상 파싱
  - `icons/btn0.png` 약 `2274 bytes`까지 감소
  - 전체 `SET_BUTTONS` ZIP `33219 bytes / 33 packets`
- 런타임 health 기준:
  - Consumer interface 연결 + 반복 `SET_BUTTONS` 송신 유지
  - sessions relay 연결 후 `sessionsCount: 2`
  - Keyboard interface는 여전히 `kIOReturnNotPermitted` 가능성 잔존

### 핵심 설계 결정
- **D200H는 텍스트보다 native label을 우선한다.** PNG는 icon-only로 유지해 펌웨어 허용 범위에 맞춘다.
- **boundary fix만으로는 부족하다.** ZIP도 표준 파서가 읽을 수 있는 형태여야 reverse-engineering / oracle 비교가 가능하다.
- **입력 fallback도 Consumer-first로 준비한다.** Keyboard HID 권한이 막히는 macOS 빌드가 실제로 존재하므로 Consumer callback을 항상 붙여 두는 쪽이 안전하다.

---

## 2026-04-05 — CLAUDE.md 재구성 + D200H Node daemon 활성화

### 문제
재부팅 후 LaunchAgent(`dev.agentdeck.daemon`)가 Node.js CLI daemon을 자동 실행했는데 D200H 화면이 안 나오고 Round AMOLED도 이상. 원인은 Node daemon이 D200H를 아예 지원하지 않아서 (`modules/index.ts`에서 `new D200hModule()`이 주석 처리되고 "Swift daemon only" 주석이 붙어있었음). Round는 WiFi provision만 반복 실패. 부가적으로 CLAUDE.md가 41.5KB/303줄까지 비대해져서 Claude Code 성능 경고 발생 (40KB 한도 초과).

### 해결
**1. CLAUDE.md 재구성** (commit e382864): "Key Design Decisions"가 24KB(58%)로 비대. 주제별로 7개 신규 `docs/*.md`에 **원문 그대로** 이식 + voice-setup.md 확장. CLAUDE.md는 9.8KB/169줄 인덱스로 축소 (76% 감소). 역할 분리 원칙 재확인 — CLAUDE.md=팀 아키텍처 인덱스, docs/=상세 기술 문서, memory/=개인 gotchas.

**2. Daemon LaunchAgent 제거**: `agentdeck daemon uninstall` + 실행 중 Node daemon stop. macOS 앱 TestFlight 배포 전까지 수동 실행 모드. 앱 배포 시 `DaemonService`가 in-process Swift daemon 자동 기동하므로 LaunchAgent 재등록 불필요.

**3. D200H Node daemon 활성화** (commit 27765c7): 기존 `bridge/src/modules/d200h-module.ts` (292줄) + `bridge/src/d200h/hid-protocol.ts` + `image-renderer.ts` 코드는 이미 완전히 존재. 3파일만 수정:
- `modules/index.ts`: `createDefaultModules()`에 `new D200hModule()` 추가
- `daemon-server.ts`: daemon 모듈 configs에 `d200h: 'auto'` 명시
- `cli.ts`: 4개 session 커맨드(claude/codex/opencode/monitor + --local)에 `d200h: false` 명시

물리 디바이스로 검증 (serial 02C35A044U3670684): Consumer Control 인터페이스 연결 → 첫 프레임 10100 bytes/10 packets 전송 성공. Keyboard 인터페이스는 macOS IOKit 제한으로 열지 못하지만 D200H는 Consumer로도 버튼 이벤트 수신 가능해서 문제 없음.

### 핵심 설계 결정
**Daemon-only 디바이스 모듈의 session bridge 격리 원칙**: `ModuleConfigs`의 d200h 기본값이 `'auto'`이기 때문에 세션 커맨드에서도 명시하지 않으면 자동 활성화돼버려 HID 디바이스 다중 오픈 충돌 발생. serial/pixoo/mdns와 같은 패턴으로 **세션 커맨드에서 반드시 `d200h: false` 명시 필요**. 기존 mdns(false)/serial(false)/pixoo(false)와 동일한 "daemon이 단일 hub" 보장 규칙.

**듀얼 구현체 유지 정책**: Swift daemon(1772줄, v4 멀티세션 UI, 15s heartbeat, 13 sessions/page) + Node.js daemon(v3-era 14-key static layout, 30s keep-alive) 병행. macOS 앱 없는 환경에서도 CLI로 D200H 기본 동작 보장. Swift는 기능 우위지만 macOS 한정. 이 결정은 `docs/plugin-conventions.md` "Two implementations (daemon-only)" 섹션에 명시.

---

## 2026-04-05 — ESP32 disconnect 복구 견고화 + TC001 상태 UI

### 문제
macOS AgentDeck 앱이 Xcode 디버그 빌드 상태에서 `open -a`로 재실행 시 `T(stopped)` 상태로 부팅 → 포트 9120 listen 되지 않음 → ESP32 보드들이 데몬을 못 찾음. 어제(04-04) 01:33 SIGTERM으로 죽은 뒤 방치된 채 좀비 `dns-sd` 자식 프로세스 4개가 `_agentdeck._tcp` 광고를 계속 유지. 클라이언트가 mDNS로는 daemon을 "발견"하지만 실제 TCP connect는 connection refused. TC001이 `SEARCHING...` 스크롤만 무한 표시. 복구는 수동으로 앱 재기동+SIGCONT 필요.

근본 약점:
- ESP32 firmware가 한 번 `wsConnect(ip,...)` 호출 후엔 그 IP만 영원히 재시도 → daemon IP 바뀌거나 listener 사라지면 리부트 전엔 복구 불가
- `WebSocketsClient`의 `setReconnectInterval()`이 최초 `wsConnect()` 시점에만 호출됨 → 우리 코드가 `reconnectMs`를 backoff로 증가시켜도 라이브러리 내부 타이머는 초기값 고정 (사실상 exponential backoff 미작동)
- 디스플레이가 stale 크리처/세션 스프라이트를 계속 렌더 → 사용자가 disconnect 상태인지 인지 어려움

### 해결
**Firmware 변경** (commit 253eca9):
- `main.cpp`: `bridgeFound` 플래그 제거. mDNS를 always-poll로 전환 → daemon IP 변경(DHCP/호스트 이동) 즉시 감지 후 `wsDisconnect()`+새 IP로 `wsConnect()` 재바인딩. WS backoff가 15초 이상 saturated이면 `mdnsRefresh()`로 캐시 강제 무효화 (좀비 dns-sd 광고 방어)
- `ws_client.cpp`: `setReconnectInterval(reconnectMs)`를 backoff 증가 시마다 재호출 (라이브러리 동기화 버그 수정). `WStype_TEXT` 수신 시 `lastMessageMs` 갱신. `wsLastAttemptMs()`/`wsBackoffMs()` getter 추가
- `serial_client.cpp`: JSON 수신 시 `lastMessageMs` 갱신
- `state/agent_state.h`: `uint32_t lastMessageMs` 필드 추가 (0 = 데이터 한 번도 못 받음)
- `ui/matrix/matrix_pages.cpp`: `SEARCHING...` → 컨텍스트 기반 동적 상태 메시지 (`CONNECT WIFI` / `FINDING BRIDGE` / `DAEMON DOWN 2m` / `NO WIFI 5m` / `OFFLINE`) color-coded by severity. 우상단에 깜빡이는 빨간 점. `renderAgents`에서 disconnect 체크를 세션 수집 이전으로 이동 → stale 크리처 렌더 방지

### 핵심 설계 결정
- **"Don't show stale data" 원칙**: 사용자가 "마지막 데이터 보여주지 말고 상태를 명확하게 표시"라고 명시. Grace period 없이 disconnect 즉시 진단 메시지로 전환. 데이터 age를 함께 표시해 사용자가 "얼마나 오래 끊겼나" 인지 가능
- **mDNS always-poll**: `bridgeFound` flag 패턴은 static configuration에만 맞고 dynamic IP 환경에 취약. 폴링 비용(5s interval)보다 IP drift 복구가 더 중요
- **Saturated backoff = zombie mDNS 신호**: 15초 이상 max backoff(8s) 연속 실패는 "TCP 레벨에서 listener가 없다"는 강력한 신호. 이때 mDNS 캐시 무효화로 좀비 광고 방어. 정상 상황(일시적 네트워크 문제)에선 트리거 안 됨
- **Backoff의 library 동기화**: ESP32용 WebSocketsClient v2.7+는 내부 타이머로 reconnect를 driver → 우리 코드가 `reconnectMs` 변수만 증가시키고 `setReconnectInterval()` 호출을 안 하면 라이브러리는 여전히 초기 interval로 재시도. 이 버그는 기존 모든 ESP32 보드에 영향 (TC001 뿐 아니라 IPS/Round/86 Box도)

### 검증
4대 모두 플래시 + 부팅 확인:
- IPS 3.5" (`usbmodem21133201`) ✓
- Round AMOLED (`usbmodem2111201`) ✓
- 86 Box (`wchusbserial211340`) ✓
- TC001 (`wchusbserial110`) ✓ — `board:ulanzi_tc001`, WiFi `192.168.68.72`, `[Serial] First JSON received` 로그 확인

---

## 2026-04-05 — Control Tower UX polish + Launch Session + MenuBarExtra 구조 재설계

### 문제
1. Control Tower 세션 카드가 sparse — model name/activity time/creature 아이콘 없음, rate limit 트렌드 없음, idle 3개 초과 시 collapse
2. 대시보드에서 크리처 클릭 시 같은 세션이 두 번 렌더 ("세션이 하나 더 생김")
3. MLX 리스트에서 nanoLLaVA가 깜빡거림 (나타났다 사라졌다)
4. Launch Session 버튼 클릭 후 터미널 안 열림 (AppleScript Automation 권한 미요청)
5. Launch Session이 특정 폴더/에이전트/터미널 선택 없이 고정 실행
6. MenuBarExtra + `.sheet` 조합에서 클릭 이벤트 불안정

### 해결
**Control Tower 개선** (`ControlTowerPanel.swift`):
- Creature SF Symbol 아이콘 (water.waves/ladybug/cloud/terminal/server.rack)
- 세션 row 서브타이틀: `agentType · modelName · 23m`
- Rate limit ↑↓ 트렌드 화살표 (1% 이상 변화 시), >70% 시 reset time orange+semibold
- Idle collapse 제거 (항상 개별 row)
- 세션 row 탭 → `focusSession` + 대시보드 열기
- 헤더: `N sessions · M active · K attention` 색상 코딩
- `previousFiveHourPercent`/`previousSevenDayPercent` state 필드 추가, usage_update 처리 전에 이전값 보존

**크리처 중복 버그 수정** (`TerrariumState.swift`):
- 원인: Focus relay가 sibling의 state_update를 broadcast하면 client `state.agentType`이 "daemon"→"claude-code"로, `sessionId`가 sibling의 id로 바뀜. `primaryIsOctopus=true`되어 primary 문어 추가하는데 siblings 리스트에 같은 id가 여전히 있어서 이중 렌더
- 해결: octopus/jellyfish/opencode 각각 `primaryIsX && $0.id == sessionId` 조건 시 siblings에서 제외

**MLX nanoLLaVA 깜빡임 수정** (`DaemonServer.swift`):
- 원인: Focus relay broadcast 핸들러가 modelCatalog/ollamaStatus는 daemon 캐시로 override하지만 mlxModels는 pass-through. 오래된 sibling bridge(필터 없는 구버전)의 state_update가 nanoLLaVA 포함 리스트 전송 → daemon의 자체 프로브(5초 주기, 필터 적용)와 번갈아 덮어쓰면서 flicker
- 해결: focus relay `setBroadcast` 핸들러에서 `state_update`의 `mlxModels`를 항상 `self.cachedMlxModels`로 override (empty면 key 제거)

**Launch Session dialog 신규 구현**:
- MenuBarExtra + `.sheet` 문제: `.menuBarExtraStyle(.window)` 위의 sheet는 focus/click event 전달 불안정 (feedback-assistant#331, Peter Steinberger 검증)
- 해결: 독립 `Window("Launch Session", id: "launch-session")` scene 선언 + `openWindow(id:)` + `NSApp.activate(ignoringOtherApps:)` (menu bar 앱 default `.accessory` policy)
- 폴더 picker (NSOpenPanel) + 에이전트 segmented (Claude/Codex/OpenCode/Plain) + 터미널 menu (Terminal/iTerm2/Alacritty/WezTerm/Ghostty/Warp)
- 터미널 자동 감지: `NSWorkspace.urlForApplication(withBundleIdentifier:)` — 설치된 것만 목록에 표시
- 실행 방식: iTerm2는 AppleScript (탭 생성 + write text), 나머지는 `.command` 파일 + `NSWorkspace.open(file, withApplicationAt: appURL)`
- `@AppStorage`로 마지막 폴더/에이전트/터미널 기억

**SessionLauncher PATH discovery**:
- App Sandbox PATH가 제한적 → `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `~/Library/pnpm`, `~/.npm-global`, `~/.nvm/current` 경로 명시적 체크 후 fallback `which`

### 핵심 설계 결정
- **Secondary windows from MenuBarExtra**: sheet ❌, 독립 `Window` scene ✅. `openWindow(id:)` + `NSApp.activate()` 필수
- **Focus relay state override**: sibling의 데이터 중 daemon이 권위 있는 것(mlxModels, modelCatalog, ollamaStatus, gatewayAvailable)은 **항상 daemon 캐시로 덮어쓴다** — sibling의 구버전/다른 필터를 신뢰하지 않음
- **Creature layout + focus relay 상호작용**: Focus relay가 primary 상태를 바꿀 때 siblings 리스트에서 ID 중복 제거는 UI가 책임진다 (프로토콜 레벨에서 제거하지 않음 — 여러 클라이언트가 다른 관점에서 해석 가능해야 함)
- **Terminal 실행**: AppleScript는 Automation 권한 프롬프트 필요 → 실패 가능. `.command` 파일 + `NSWorkspace.open`은 sandbox-safe + 사용자 기본 터미널 존중

### 후속 이슈 (해결 — 35b1f45)
- **Settings 진입점 중복** ✅: `MonitorScreen` gear 버튼을 macOS에서 `showSettingsWindow:` selector 경유로 통일 (`openSettings()` 헬퍼 + `#if os(macOS/iOS)` 분기). iOS는 `.sheet` 유지
- **`openDashboard` window 검색** ✅: `title.contains()` 제거. SwiftUI `openWindow(id:)` 이미 존재 window를 front로 가져오므로 fallback 자체가 불필요 — `openWindow(id:)` + `NSApp.activate` 2줄로 축소, `openLaunchSession`도 동일 처리. i18n-safe

---

## 2026-04-04 — E-ink canvas.drawPath() silent fail + 크리처 크기 정규화

### 문제
Crema S e-ink 디바이스에서 모든 크리처가 미표시. 수조 배경(물, 모래, 해초)은 보이지만 문어/가재/구름/OpenCode/물고기 전부 안 보임. 태블릿(Lenovo)에서는 정상. 또한 크리처 크기가 플랫폼 간 불균형.

### 해결
1. **근본 원인**: e-ink Canvas 구현체가 `canvas.drawPath()` 호출을 silent fail. Path 오브젝트(SVG path, Path.op UNION, cubicTo 등)는 렌더링 없이 무시됨. `drawRect()`/`drawCircle()`/`drawOval()`/`drawLine()` 기본 프리미티브는 정상 동작
2. **Claude Code**: SVG PathParser → 12×8 `drawRect` 픽셀 그리드 (ESP32 SSOT 일치)
3. **Cloud (Codex CLI)**: `Path.op(UNION)` + `drawPath` → 개별 `drawCircle()` 7개 (6 lobes + center)
4. **Crayfish**: cubicTo SVG body/claw → `drawOval()` body + legs + `drawLine()` antennae
5. **Animation loop**: try-catch 추가 — 크래시가 코루틴을 kill하여 초기 렌더(agents=0) 고정되는 문제 방어
6. **크기 정규화**: OpenCode -20% (0.08→0.064 Android, 0.055→0.044 Apple), Claude Code -10% (0.055→0.050), OpenCode 테두리 inner 50%→60% (더 얇게)

### 핵심 설계 결정
- **E-ink에서 `drawPath()` 금지** — Rockchip RK3566 (Crema S) Canvas 구현이 complex Path를 지원하지 않음. 향후 e-ink 크리처 추가 시 반드시 기본 프리미티브만 사용
- **Animation loop crash 방어**: try-catch 없으면 한 크리처의 렌더 실패가 전체 수조 갱신을 영구 중단시킴 (coroutine silent death). 초기 렌더만 표시되어 "배경만 보이고 크리처 없음" 증상 발생
- **진단 팁**: `adb logcat | grep EinkFrame`으로 agents/clouds/oc/crayfish count 확인 가능

---

## 2026-04-04 — macOS App 전면 점검 + AI Control Tower MenuBarExtra

### 문제
macOS Swift daemon의 Node.js 대비 기능 갭, 슬립/네트워크 복원 리스크, MenuBarExtra 단순함, 세션 정렬 파편화 등 전반적 개선 필요.

### 해결 (Phase 1-4 전체 구현)

**Phase 1 — 인프라 안정성:**
- `NWPathMonitor` — WiFi/VPN/IP 변경 시 Bonjour republish + module wake + timeline sync (2s debounce)
- `SIGTERM` handler — Activity Monitor 강제 종료 시 daemon.json 정리 + crash log
- Stale data indicator — 연결 끊김 시 "Data from Xm ago" 배지 + 패널 60% dim
- Shutdown timeout 3s→5s + fallback `removeDaemonInfo()`

**Phase 2 — AI Control Tower:**
- `shared/src/session-utils.ts` — `stateRank`, `sortSessions`, `assignDisplayNames` 공통화. TUI/Plugin/aggregator/Apple/Android 6곳 통합. Plugin `projectName` mutation 버그 수정
- `MenuBarExtra(.window)` — 340×450 Control Tower 패널 (Attention/Active/Idle 3-tier, Models & Services, Rate Limits, Devices, Actions)
- `session_command` protocol — daemon이 특정 세션에 명령 포워딩 (shared type + Node.js + Swift)
- Voice TTS flow — PROCESSING→IDLE 시 chat_end TTS 응답
- Bonjour republish 3회 retry + TimelineRelay subscription cap 20
- Antigravity 15s SQLite probe, wake_word_detected broadcast callback
- Dashboard 관제: 키보드 숏컷 (⌘Y/N/⏎/.), 크리처 탭→세션 포커스, 토스트 알림

**Phase 3-4 — UX 폴리시:**
- Connection error recovery guidance ("Retry Discovery" 버튼)
- PID reuse guard (24h staleness), health check 5s
- Antigravity sandbox 설명, remove access confirmation dialog
- Session list overflow (10개 cap), tool progress lineLimit(2)

### 핵심 설계 결정

1. **MenuBarExtra label은 단순 `Image(systemName:)`만 사용** — ZStack/overlay/resizable Image는 NSStatusBar 변환 시 렌더링 깨짐 (긴 하얀 박스). 크리처 SVG는 패널 내부에서만 사용
2. **SwiftUI `Text(verbatim:)` 필수** — `Text("\(port)")`는 locale-aware라 9120→"9,120" 쉼표 삽입. 포트/토큰 등 숫자는 반드시 `verbatim:`
3. **세션 정렬은 shared에서 1곳 관리** — `(projectName, agentType)` 튜플로 번호 부여, 원본 mutation 금지 (Plugin이 `s.projectName = "#1"` 직접 수정하던 버그)
4. **Codex review P2 반영** — TimelineRelay failed 포트가 sync()에서 재구독 안 되는 버그 수정 (`!knownPorts.contains || subscriptions[port] == nil`)
5. **disconnectBridge() 버그 수정** — `preferredLocalBridgeUrl` 미초기화로 Settings 연결 해제 무효화

---

## 2026-04-04 — ESP32 시리얼 FD 누수 + TC001 SEARCHING 고착

### 문제
TC001이 SEARCHING 상태에서 벗어나지 못함. Daemon `/health` status에서 `connected: true`이지만 `deviceInfo: null`.

### 진단
1. **Daemon 사망**: `daemon.json`의 PID가 실제 프로세스 없음 → 아무도 TC001에 JSON을 보내지 않음
2. **FD 누수**: daemon restart/wake 사이클마다 같은 시리얼 포트에 `open()` 호출하지만 기존 FD 미해제. `lsof`로 확인 시 `/dev/cu.wchusbserial211340`에 FD 5개 동시 오픈
3. **macOS tty 경합**: 동일 시리얼 디바이스에 복수 FD open 시 커널 tty 레이어에서 데이터 송수신 불안정

### 근본 원인
- `stop()` — `pollTask?.cancel()` 후 task 완료를 기다리지 않아 in-flight `pollForDevices()`가 새 FD를 열 수 있음
- `handleWake()` — connections 수동 정리 + 즉시 `pollForDevices()` 호출, 기존 read thread가 아직 살아있어 FD 미해제
- `openAndRegisterPort()` — 같은 포트에 기존 연결이 있어도 확인 없이 새로 open

### 해결 (`ESP32Serial.swift`)
- `ReadToken` 클래스: read thread 종료를 thread-safe하게 제어
- `openAndRegisterPort()`: 같은 포트 기존 연결 close → token invalidate → 제거 후 open
- `stop()` → `async`: `await pollTask?.value` 등으로 task 완료 대기
- `handleWake()` → `await stop()` + `start()` (완전 정지 후 재시작)
- heartbeat: `deviceInfo == nil` 30초 타임아웃 시 자동 재연결

### 핵심 교훈
- **macOS 시리얼 포트 복수 open은 위험**: 같은 `/dev/cu.*`에 여러 FD가 열리면 tty 레이어 경합으로 데이터 전달이 불안정해진다
- **Swift actor Task cancel은 cooperative**: `cancel()` 호출만으로는 즉시 중단 안 됨. `await task?.value`로 완료 대기 필수
- **D200H 로그 이중 출력**: `debugLog()`가 `DaemonLogger` + `NSLog` 양쪽에 써서 Xcode 콘솔에 2줄씩 출력됨 (broadcast 중복이 아님)

---

## 2026-04-04 — ESP32 스와이프 제스처 차단 버그 + 디버그 로그 정리

### 문제
1. **86 Box 스와이프 미동작**: 연결 오버레이(`connScrim`)가 표시될 때 스와이프 제스처가 동작하지 않음
2. **디버그 로그 잔류**: 이전 세션 `#if constexpr` 디버깅 중 추가한 `Serial.printf` 로그가 `renderer.cpp`와 `opencode.cpp`에 남아있음
3. **86 Box + IPS 3.5" 부트루프**: 두 보드 모두 "No bootable app partitions" 부트루프 (이전 세션 플래시 실패 추정)

### 해결
1. **제스처 버그**: `connScrim`이 `LV_OBJ_FLAG_CLICKABLE`으로 전체 화면을 덮어 터치 이벤트를 가로챔. 제스처 핸들러는 `screen`에만 등록. `LV_OBJ_FLAG_EVENT_BUBBLE` 추가로 이벤트가 부모 `screen`으로 전파되도록 수정
2. **디버그 로그**: `renderer.cpp` (terrarium 세션 덤프 3초 주기) + `opencode.cpp` (렌더 진단 로그 + `Draw::rect` RGB565 임시 분기) 제거. `opencode.cpp`는 `fillRectA` 통일
3. **부트루프 복구**: bootloader + partitions + firmware full flash. IPS 3.5"는 `--flash_size 16MB` 필수

### 핵심 교훈
- **LVGL 9 이벤트 전파**: `LV_OBJ_FLAG_CLICKABLE` 오버레이는 제스처를 포함한 모든 입력 이벤트를 소비. 반투명 오버레이에는 `LV_OBJ_FLAG_EVENT_BUBBLE` 필수
- **부트루프 시 CH340 포트는 살아있다**: 부팅 실패해도 CH340/Native USB 시리얼은 접속 가능. 시리얼 출력으로 부트루프 원인 확인 후 full flash로 복구

---

## 2026-04-03 — D200H Hybrid Icon Buttons (Firmware-Safe PNG + Native Text)

### 문제
D200H는 Stream Deck처럼 풍부한 시각 버튼 UX를 맞추고 싶지만, 지금 구현은 사실상 네이티브 텍스트 중심이었다. 과거 시도에서 CoreText로 긴 텍스트를 PNG에 직접 그리면 ZIP 크기와 경계 바이트 조건 때문에 D200H 펌웨어가 `SET_BUTTONS`를 거부하고 기본 시계 화면으로 되돌아가는 문제가 있었다.

### 해결
- `apple/AgentDeck/Daemon/Modules/D200hHidModule.swift`
  - 버튼 구조체에 `icon` / `iconColor` 추가
  - 세션 타입별 배지 추가:
    - Claude Code = octopus
    - Codex CLI = jellyfish/cloud
    - OpenCode = nested square
    - OpenClaw = crayfish
  - 액션 버튼도 전용 glyph 추가:
    - Back / Stop / More / Go On / Review / Commit / Clear / Tool / Usage
  - PNG 렌더러는 더 이상 텍스트를 직접 그리지 않고, 상단의 **희소(sparse) 벡터형 아이콘**만 그린다
  - 라벨은 계속 기기 네이티브 `manifest Text`로 처리
  - 라벨 스타일은 `Size 14 / Weight 72`로 낮춰 아이콘과 텍스트 공존 공간 확보

### 핵심 설계 결정
- **완전 이미지 버튼보다 하이브리드가 안전하다.** D200H는 이미지 자체가 불가능한 게 아니라, 큰 텍스트 렌더가 포함된 PNG와 ZIP이 펌웨어 허용 범위를 쉽게 넘는다.
- **아이콘은 sparse path로만 렌더한다.** 넓은 채움면과 텍스트 래스터를 피하면 PNG 압축률이 좋아지고 boundary padding 우회 성공 확률이 올라간다.
- **텍스트는 계속 native renderer 사용**: 가독성과 펌웨어 안정성을 둘 다 지키기 위한 절충안.

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200Hybrid build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

---

## 2026-04-03 — 크리처 배치 전면 개선: 지면 근접 + 타입 간 분리

### 문제
- Apple/Android: 모든 크리처 idle Y가 0.55~0.59로 지면(0.65)에서 6~10% 위에 떠 있음
- 4가지 크리처 타입이 Y 4% 폭(0.55~0.59)에 몰려 겹침 심각
- 수영 영역도 모든 타입이 동일 범위 사용 → 활동 중 겹침
- Pixoo: idle Y 0.76에서 sand(px54)까지 5px 떠 있고, HUD(px57)와도 겹침 발생

### 해결
- **Idle Y 지면 근접**: Oct 0.62, Cloud 0.60, OC 0.61, Crayfish 0.64 (sand 0.65 바로 위)
- **타입별 X 영역 분리**: Octopus 좌측(0.20-0.50), Cloud 중앙(0.30-0.55), OpenCode 우측(0.45-0.68)
- **Swim Y 분리**: Cloud 상층(0.05-0.25), OpenCode 중상층(0.25-0.50), Octopus 중층(0.15-0.55)
- **ESP32**: homeX 분산(Oct 0.30, Cloud 0.50, OC 0.65), per-creature swim bounds 도입
- **Pixoo**: LOD 스프라이트 크기(Oct 7×7, JF 7×5, OC 6×6, CF 13.5px tall) 역산하여 sand(px54) 접지 + HUD(px57) 3px gap 확보. Crayfish는 큰 스프라이트(13.5px)로 center Y가 높지만 발 위치는 동일
- **TUI**: 이미 0.85~0.88로 양호, 변경 불필요

### 핵심 설계 결정
- 크리처 "발 위치"(sprite bottom) 기준 정렬 — center Y는 스프라이트 크기에 따라 다를 수 있음
- 완전한 X 영역 분리 대신 중심 이동 + 약간의 겹침 허용 (한 타입이 다수일 때 충분한 폭 필요)
- 대상: Apple(SwiftUI) + Android(Kotlin) + ESP32(C++) + Pixoo(Swift daemon). TUI 제외

---

## 2026-04-03 — ESP32 OpenCode 크리처 미표시 근본 원인 + 멀티플랫폼 크리처 수정

### 문제
ESP32 3종(86 Box, IPS 3.5", Round AMOLED)에서 OpenCode 크리처가 전혀 표시되지 않음. Android/Apple/Pixoo에서는 정상. 시리얼 로그에서 `opencodeCount=1`으로 데이터는 수신되나 렌더링 안 됨.

### 해결
**근본 원인**: `config.h`의 `MAX_OPENCODE`가 `constexpr uint8_t`인데 `renderer.cpp`에서 `#if MAX_OPENCODE > 0`으로 감싸고 있었음. C 전처리기는 constexpr을 인식 못하고 미정의 심볼을 0으로 평가 → OpenCode 렌더링/상태매핑/이름태그 코드 전체가 **모든 보드에서 컴파일 제외**. Octopus/Cloud는 `#if` 가드 없이 for 루프로 되어있어 정상 동작.

수정: `#if`/`#endif` 6곳 → 런타임 `if (MAX_OPENCODE > 0)` 또는 가드 제거.

### 추가 수정 (동일 세션)
1. **Android Claude Code**: 14×5 픽셀 그리드 → claudecode.svg SVG 경로 렌더링 (PathParser + EvenOdd)
2. **Android E-ink OpenCode**: bodyWidth 0.14→0.11 (다른 크리처와 크기 정규화)
3. **StreamDeck**: CLAUDE_LOGO_PATH sparkle→Antigravity 로봇 경로, opacity 향상
4. **Apple TerrariumRenderer**: OpenCode 라이프사이클 완전 누락 → syncOpenCode() + draw 레이어 추가
5. **전 플랫폼 아이콘**: OpenCode 세션 목록 아이콘 누락 → ▣ (nested square) 아이콘 + SVG 경로 추가 (Android BrandIcon, Apple BrandIcon, TUI creatureEmoji, E-ink agentIcon)
6. **ESP32 UI**: IPS 3.5" 회전 버튼 (좌측 하단), 타임라인 빈 상태 안내 메시지

### 핵심 설계 결정
- **ESP32 constexpr vs #define**: `config.h`의 상수는 `constexpr`이므로 전처리기 `#if`와 함께 사용 불가. 향후 조건부 컴파일이 필요하면 `#define` 사용하거나, 런타임 `if`/for 조건으로 처리 (컴파일러가 dead code elimination 수행)
- **IPS 3.5" full flash**: flash_size 16MB 명시 필수 (부트루프 중 8MB 오감지 방지)
- **Daemon 시리얼 점유**: 플래시 전 `lsof` 체크 필수

---

## 2026-04-03 — TC001 Usage 가독성 + 에이전트 크리처 분리 + Serial Heartbeat 수정

### 문제
(1) TC001 Usage 게이지에서 퍼센트/리셋 시간 텍스트가 Pixoo 대비 가독성 떨어짐 — fill과 텍스트가 같은 색이거나 대비 부족 (2) 에이전트 4종(Claude/Codex/OpenCode/OpenClaw) 실행 중인데 TC001에서 문어 3개 + 가재 1개만 표시 — Codex/OpenCode 전용 스프라이트 없음 (3) AGENTS 페이지 스크롤이 끝에서 갑자기 처음으로 점프 (4) ESP32 기기에서 간헐적 SEARCHING 표시

### 해결
**Usage 가독성**: Pixoo 전략 벤치마크 — fill을 20% 밝기로 어둡게 (dimFill), 퍼센트 텍스트 흰색(255,255,255), 리셋 시간 Pixoo와 동일 (0x60,0x70,0x80). 리셋 시간 포맷 후행 단위 생략 (`3H22M`→`3H22`).

**에이전트 크리처**: 해파리(SPR_JELLYFISH, Codex CLI), 네모(SPR_OPENCODE, OpenCode) 5×6 스프라이트 추가. AgentKind enum으로 타입별 색상+스프라이트 분리. OpenCode=cyan 색조.

**스크롤**: ping-pong 방식 (3초 멈춤→ease-in-out 슬라이드→3초 멈춤→역방향 슬라이드). smoothstep `t*t*(3-2t)` 적용.

**Serial Heartbeat**: Swift daemon `lastStateEvent`가 startup 시 nil → heartbeat에서 JSON 미전송 → 10초 timeout → SEARCHING. (1) startup 직후 `buildFullStateEvent()` seed (2) heartbeat 5s→3s 단축 (3) state/usage 모두 nil일 때 `{"type":"keepalive"}` fallback.

### 핵심 설계 결정
- **TC001 퍼센트 텍스트는 흰색**: 게이지 컬러 텍스트는 fill 위에서 대비 없음. LED 매트릭스 특성상 밝은 흰색이 dimmed fill 위에서 가장 선명
- **Fill 밝기 20%**: Pixoo의 35%보다 더 낮게 — LED 매트릭스는 LCD보다 밝은 색이 눈에 더 강하게 번져서 더 어두운 fill이 필요
- **Keepalive JSON 패턴**: ESP32 serial_client는 `serialBuf[0]=='{'` 체크만으로 JSON 인식 → `{"type":"keepalive"}`도 `lastSerialJsonMs` 갱신. TC001 코드 수정 불필요
- **Daemon serial port 점유**: daemon이 시리얼 포트를 열고 있으면 esptool 플래시 실패 — daemon 중지 후 플래시 필수

---

## 2026-04-03 — Usage Dial Text Overlap Fix + D200H Usage Monitor

### 문제
(1) SD+ Usage Dial (E3) overview 페이지에서 사용량 %와 리셋 시간 텍스트가 겹침 — 같은 Y좌표에 배치 (2) Detail 페이지에서 20px 한 줄에 "100% · 23h 59m" 합쳐서 200px 초과 (3) Extra Usage 페이지에 크레딧 정보 미표시 (4) D200H에 사용량 모니터링 없음

### 해결
**Plugin Usage Dial**: overview에서 % text-anchor="end" 우측 정렬 + 리셋 시간 별도 줄 분리 (y+11). Detail에서 % 단독 라인 + "resets in Xh" 별도 줄. Extra Usage에 $used/$limit 표시. 리셋 시간 폰트 11px→13px, 색상 밝게.

**D200H**: 세션 리스트 모드에서 slot 12(우측 하단)를 usage monitor로 할당. 12 sessions/page + 1 usage. "5H XX% Xh\n7D YY% Xh" 텍스트 + color-coded solid border (green/yellow/red).

### 핵심 설계 결정
- **D200H에서 커스텀 PNG 렌더링 금지**: Core Graphics로 게이지 바를 직접 그린 PNG는 ZIP 크기/바이트 경계 변화로 D200H 펌웨어가 거부 (울란지 기본 시계 복귀). 표준 renderButtonPng만 사용하고 시각 정보는 디바이스 네이티브 텍스트 + border color로 전달
- **Extra Usage 데이터 파이프라인**: protocol의 extraUsageMonthlyLimit/extraUsageUsedCredits를 UsageModeData로 전달, 렌더러에서 "$X.XX / $Y.YY" 표시

---

## 2026-04-03 — D200H Multi-Session Agent Controller + Heartbeat Resilience

### 문제
D200H HID 통신 완성 후: (1) 버튼 레이아웃이 SD+ 구조를 기계적 복사, UX 의도 없음 (2) daemon 재시작/USB 재연결 시 기본 화면 복구 안 됨 (3) Node.js bridge d200h-module이 동시에 써서 화면 충돌

### 해결
- 세션 중심 멀티 에이전트 컨트롤러: 13슬롯 전부 세션 → 누르면 detail view (퀵 액션 + ESC/STOP)
- Heartbeat 15초: SET_BUTTONS 강제 재전송 + keep-alive
- Node.js bridge에서 D200H 모듈 제거: Swift daemon 단독 제어
- SD+ 플러그인에 CC 퀵 액션 프리셋 추가

### 핵심 설계 결정
- **CoreText PNG는 D200H가 거부** — ~4KB+ PNG는 디바이스가 무시. device native text(manifest Text)만 사용
- **seize 불필요** — D200H 커스텀 프로토콜(0x7C7C)은 macOS hidd가 intercept 안 함
- **App Sandbox 호환** — sandboxed macOS 타깃은 `com.apple.security.device.usb` entitlement가 있어야 HID `IOHIDDeviceOpen`이 가능하다. Keyboard 인터페이스는 여기에 더해 Input Monitoring 재승인/앱 재시작이 필요할 수 있다
- **strmdck 프로토콜 참조** — Ulanzi SDK 미사용, 커뮤니티 리버스 엔지니어링 기반

---

## 2026-04-03 — Usage 5H Oscillation Fix (Dual Broadcast Path)

### 문제
클라이언트(Android/Apple/TUI)에서 5H rate limit 퍼센트가 두 값 사이를 왔다갔다하는 현상. 예: 5%↔22% oscillation.

### 원인
Swift/Node.js daemon이 `usage_update`를 **두 경로**로 클라이언트에 전송:
1. **SessionFocusRelay**: bridge의 WS `usage_update` pass-through (5s tick, TS `adjustUsagePercent` 이미 적용)
2. **Daemon 자체 `broadcastUsage()`**: HTTP `/usage` relay로 raw 데이터 fetch → Swift/TS `adjustUsagePercent` 적용 (60s poll + 5s tick)

Bridge API poll(90s)과 daemon HTTP relay(60s) 주기가 다르고, API 슬라이딩 윈도우 값이 시간에 따라 변하므로, relay와 daemon 자체 broadcast가 다른 값을 가짐. 또한 HTTP relay 실패 시 stale 캐시를 broadcast하여 차이 확대.

### 해결
SessionFocusRelay가 `usage_update`를 수신하면 daemon의 `cachedApiUsage`를 동기화:
- `onUsageRelayed` 콜백으로 fiveHourPercent/sevenDayPercent를 relay 값으로 덮어씀
- `apiUsagePreAdjusted` 플래그: relay에서 온 값은 이미 adjusted이므로 daemon `buildUsageEvent()`에서 `adjustUsagePercent()` 스킵
- `fetchUsageRelayed()` 성공 시 플래그 리셋 (raw 데이터이므로 adjustment 필요)

### 핵심 설계 결정
- **Single source of truth**: relay가 bridge의 최신 값을 daemon cache에 반영 → daemon tick도 동일 값 broadcast
- **Pre-adjusted 플래그**: 이중 adjustment 방지. TS와 Swift의 `adjustUsagePercent` 차이(grace period 등) 영향 제거
- **양쪽 daemon 수정**: Swift(`DaemonServer.swift` + `SessionFocusRelay.swift`)와 Node.js(`daemon-server.ts` + `bridge-core.ts` + `usage-event.ts`) 모두 동일 패턴 적용

---

## 2026-04-03 — App Store-Safe Optional Antigravity Access + Dashboard Preferences

### 문제
Antigravity 상태 표시는 `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`를 직접 읽는 방식이었다. 이 경로는 개발 환경에서는 동작할 수 있어도, Mac App Store 배포 기준의 App Sandbox와 맞지 않는다. 또한 Dashboard와 메뉴바 동작, Tank Status 섹션 노출 여부를 사용자가 조정할 방법이 부족했다.

### 해결
- `apple/AgentDeck/App/AppPreferences.swift`
  - 앱 전역 환경설정 객체 추가
  - Dashboard 자동 열기, 메뉴바 아이콘 스타일, Session list / Tank status / Timeline / Settings button 노출 여부 저장
  - `OpenClaw / MLX / OLLAMA / Antigravity / Subscriptions` 섹션별 표시 여부 저장
  - Antigravity DB는 보안 범위 북마크(security-scoped bookmark) 기반 opt-in 접근으로 전환
  - 기본값은 `showAntigravitySection = false`
- `apple/AgentDeck/Daemon/Core/UsageAPIClient.swift`
  - 더 이상 사용자 홈 경로를 직접 훑지 않음
  - `AppPreferences.shared.withAntigravityDatabaseAccess`를 통해 사용자가 승인한 `state.vscdb`에만 접근
  - Antigravity 정보는 확실한 `planName`을 읽을 수 있을 때만 생성
- `apple/AgentDeck/App/AgentDeckApp.swift`
  - 메뉴바에서 Dashboard show/hide 토글 제공
  - 메뉴바 아이콘 스타일을 `Status / App / Minimal`로 선택 가능하게 함
- `apple/AgentDeck/UI/Settings/SettingsScreen.swift`
  - Dashboard 패널/섹션 표시 제어 UI 추가
  - Antigravity 접근 허용/제거 UI 추가

### 원칙
- **App Store-safe by default**: Antigravity는 기본적으로 꺼져 있고, 사용자가 직접 파일 접근을 허용해야만 표시
- **정보를 못 읽으면 표시하지 않음**: 애매한 fallback이나 추정값 없이, 확실한 로컬 상태만 노출
- **Dashboard는 사용자 취향에 맞게 조정 가능**: 메뉴바 아이콘, Dashboard 자동 열기, Tank Status 섹션을 환경설정으로 제어

## 2026-04-03 — CLI Daemon Parity for Engine Snapshot + Antigravity + MLX

### 문제
최근 엔진 상태 섹션(`OpenClaw / OLLAMA / MLX / Subscriptions / Antigravity`)과 MLX/Antigravity probe 보강은 주로 Swift daemon 경로에 먼저 반영돼 있었다. 그 결과 기존 Node CLI daemon은 같은 Dashboard를 띄워도 다음 차이가 남아 있었다.

- `usage_update`에 `modelCatalog`와 `antigravityStatus`가 빠져 초기 동기화가 약했음
- `state_update`에 `mlxModels`, `subscriptions`, `antigravityStatus`가 없어 Dashboard 섹션이 경로마다 다르게 채워졌음
- MLX probe가 `/v1/models`만 가정해, `/models`를 쓰는 로컬 MLX 서버를 빈 상태로 오인했음
- Antigravity 로컬 quota는 Swift daemon에서만 보이고 CLI daemon에서는 비어 있었음
- OpenClaw model catalog / Ollama / MLX 값이 바뀌어도 partial event만 보내 초기 화면이 엇갈릴 수 있었음

### 해결
- `bridge/src/mlx-probe.ts`
  - MLX probe를 `/v1/models` 우선, `/models` fallback으로 확장
- `bridge/src/antigravity-local.ts`
  - `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`를 직접 읽는 local-only Antigravity parser 추가
  - `planName`, `availableCredits`, `minimumCreditAmountForUsage` 추출
- `bridge/src/usage-event.ts`
  - `buildUsageEvent()`가 `modelCatalog`, `antigravityStatus`까지 포함하도록 확장
  - `buildSubscriptions()`를 공용 helper로 분리
- `bridge/src/bridge-core.ts`
  - `state_update`와 `usage_update` 양쪽에 공통 엔진 스냅샷이 실리도록 정리
  - `mlxModels`, `subscriptions`, `antigravityStatus`를 state payload에 포함
  - `cachedAntigravityStatus` 캐시 추가
  - `startOllamaProbe()` / `startMlxProbe()`가 값 변경 시 즉시 `state_changed`를 emit하도록 보강
  - `startAntigravityProbe()` 추가
- `bridge/src/index.ts`, `bridge/src/daemon-server.ts`
  - `model_catalog` 수신 시 partial `state_update` 대신 `core.buildStateEvent()` + `broadcastUsage()`로 full snapshot 재전송
  - CLI session / daemon startup 모두 `startAntigravityProbe()` 시작
- `bridge/src/types.ts`
  - protocol 타입은 `@agentdeck/shared/protocol`에서 직접 재수출하도록 정리해 workspace 타입 해석 안정화

### 핵심 설계 결정
- **Swift와 CLI daemon은 같은 엔진 상태 스냅샷 규약을 써야 한다.** 특정 UI가 어느 daemon에 붙느냐에 따라 `OpenClaw / MLX / Subscriptions / Antigravity` 표시가 달라지면 안 된다.
- **Antigravity는 local-only 유지**: cloud/API fallback 없이, 로컬 IDE가 저장한 상태가 있을 때만 표시한다.
- **probe 변화는 partial patch가 아니라 full snapshot으로 재브로드캐스트**: 초기 연결 이후에 들어온 model catalog / MLX / Antigravity 값도 Dashboard 전체가 일관되게 갱신되도록 맞췄다.

### 검증
- `pnpm --filter @agentdeck/shared typecheck`
- `pnpm --filter @agentdeck/shared build`
- `pnpm --filter @agentdeck/bridge typecheck`
- `pnpm --filter @agentdeck/bridge build`
- 결과: 모두 통과

---

## 2026-04-03 — Antigravity Local-Only Quota Surface in Swift Daemon + Dashboard Panels

### 문제
Antigravity 사용량은 외부 유틸리티(`antigravity-usage`)로는 확인할 수 있었지만, AgentDeck Dashboard 기본 기능으로는 보이지 않았다. 또한 cloud/API fallback 없이, Antigravity IDE가 이미 가진 로컬 상태만 이용해 가능한 경우에만 표시하고 싶었다.

### 해결
- `apple/AgentDeck/Daemon/Core/UsageAPIClient.swift`
  - Antigravity 로컬 DB `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`를 직접 읽는 local-only parser 추가
  - `antigravityAuthStatus`의 `userStatusProtoBinaryBase64`에서 `Google AI Pro/Ultra/...` 플랜 문자열 복구
  - `antigravityUnifiedStateSync.modelCredits` protobuf/base64 sentinel 값을 풀어 `availableCredits`, `minimumCreditAmountForUsage` 복구
- `apple/AgentDeck/Daemon/Server/DaemonServer.swift`
  - `state_update`와 `usage_update`에 `antigravityStatus`를 함께 실어 대시보드 초기 연결 시점에도 엔진 정보가 빠지지 않도록 연결
- `shared/src/protocol.ts`, `apple/AgentDeck/Model/Protocol.swift`, `android/.../Protocol.kt`
  - 공용 `AntigravityStatusInfo` 필드 추가
- `TankStatusPanel.swift`, `EnginePanel.kt`, `EinkStatusPanel.kt`, `EinkStatusCompact.kt`
  - 값이 실제로 있을 때만 `Antigravity`/`AG` 섹션 표시
  - 큰 화면은 `Google AI Pro · 1000 cr · min 50`, e-ink는 `Pro · 1000cr`처럼 더 압축된 표현 사용

### 핵심 설계 결정
- **fallback 없이 local-only**: Google Cloud API나 별도 로그인 경로는 붙이지 않고, Antigravity IDE가 이미 가진 로컬 상태가 있을 때만 표시
- **프로토콜은 optional 확장**: Node daemon이 아직 이 값을 생산하지 않아도, Swift daemon 경로에서만 우선 표시 가능하도록 optional 필드로 추가
- **구독 정보와 사용량 정보 분리**: `Subscriptions`와 별개로 `Antigravity` 섹션에서 플랜과 크레딧을 함께 보여 usage 성격을 더 명확히 함

### 검증
- `pnpm --filter @agentdeck/shared typecheck`
- `./gradlew :app:compileDebugKotlin`
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataAntigravity build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

## 2026-04-03 — TC001 SEARCHING Flap 조사 및 Swift Serial 안정화

### 문제
Ulanzi TC-001가 정상 화면과 `SEARCHING...` 오버레이 사이를 반복했다. Swift daemon `/health`에서는 `/dev/cu.wchusbserial211340`가 반복적으로 `connected: false`로 떨어졌고, `~/.agentdeck/swift-daemon.log`에는 약 10초 간격으로 `Opened` → `device_info` → `Read exit ... errno=9` 패턴이 계속 남았다.

### 해결
- `apple/AgentDeck/Daemon/Modules/ESP32Serial.swift`
  - UART(CH340/CP210x) termios 설정에서 `HUPCL`을 명시적으로 끄도록 변경해 Node serial bridge의 `stty ... -hupcl` 동작과 맞춤
  - serial read thread가 `FileHandle`을 명시적으로 retain 하도록 변경해 reader lifetime이 connection struct/ARC 타이밍에 간접 의존하지 않게 보강
  - `<<EOF>>` 단일 sentinel 대신 `errno`와 strerror를 포함한 read failure 메시지를 health/log에 남기도록 개선

### 핵심 설계 결정
- **Swift daemon은 Node serial bridge와 포트 제어 parity를 유지해야 한다.** 특히 UART 계열 ESP32는 `hupcl` 여부에 민감하고, Node 구현에서 이미 안정화된 termios 차이를 그대로 두면 보드별로만 재현되는 플랩이 생길 수 있다.
- **fd lifetime은 암묵적 보장에 맡기지 않는다.** read thread가 raw fd만 들고 돌면 handle 소유권이 struct copy/cleanup 경로에 묻히기 쉽다. reader가 직접 handle을 붙잡아 두는 편이 안전하다.

## 2026-04-03 — Android E-ink Terrarium Multi-Agent Consistency Fix

### 문제
CremaS에서 ADB를 수동 활성화한 뒤에도 terrarium 표시가 세션 목록과 일치하지 않았다. 상단에는 `OpenClaw`, `Codex CLI`, `OpenCode`, 일반 coding agent 세션이 보이는데, 수조에는 일부 생물만 보이거나 idle 생물들이 한 지점에 겹쳐 보였다. 또한 저장된 WiFi URL이 있으면 USB `adb reverse`보다 먼저 그 주소로 재접속을 시도해, USB 연결을 켠 직후에도 경로가 일관되지 않았다.

### 해결
- `android/.../EinkRenderer.kt`
  - `drawEinkCloud()`와 `drawEinkOpenCode()`가 idle/sleep 상태에서 전달받은 레이아웃 슬롯을 무시하고 고정 Y 좌표로 내려앉던 문제를 수정
  - 모든 상태에서 `centerXFraction` / `centerYFraction` 기반으로 배치하고, 상태별로는 작은 bob 애니메이션만 더하도록 변경
- `android/.../MainActivity.kt`
- `android/.../ui/screen/EinkMonitorScreen.kt`
  - 자동 연결 순서를 `saved URL → localhost → mDNS`에서 `localhost → saved URL → mDNS`로 변경
  - CremaS처럼 USB `adb reverse`가 가능한 기기에서 저장된 WiFi URL 때문에 경로가 흔들리지 않도록 정렬
- `android/.../state/AgentState.kt`
  - daemon/openclaw aggregate 상태에서 relayed child session `state_update`가 들어와도 primary `agentType`/project/model이 불필요하게 흔들리지 않도록 안정화

### 검증
- `./gradlew :app:compileDebugKotlin`
- `bash scripts/build-android-release.sh`
- `adb -s CREMAA21W09235 install -r dist/agentdeck-v0.3.0.apk`
- 설치 후 CremaS 캡처에서 `OpenCode` square, `Codex CLI` cloud, `OpenClaw`, 일반 agent가 동시에 보이는지 확인
- `adb shell ss -tan | rg 9120` 결과가 `127.0.0.1:9120` loopback 연결로 잡히는 것 확인

---

## 2026-04-02 — Swift Daemon ESP32 Serial 통신 수정

### 문제
Swift daemon에서 ESP32 3대 (Round AMOLED, IPS 3.5", Ulanzi TC001)가 모두 화면이 꺼져 있었다. 시리얼 포트 4개가 connected 상태였지만 `deviceInfo`가 전부 null — device_info 응답을 수신/파싱 못함.

### 해결
3중 버그:
1. **FileHandle.readabilityHandler 미작동**: macOS의 dispatch source가 시리얼 포트 fd에서 제대로 트리거되지 않음 → DispatchQueue + `Darwin.read()` 50ms polling으로 교체
2. **Swift actor executor 경합**: read 스레드에서 `Task { await actor.handleReadData() }` 호출 시 actor 접근이 대기 상태에 빠짐 → `NSLock` 기반 `pendingReads` 큐 + heartbeat 주기에 `drainPendingReads()` 호출로 교체
3. **CR 줄바꿈 미인식**: `cfmakeraw`가 `ICRNL`(CR→LF 입력 변환)을 비활성화하여 ESP32의 `Serial.println()`이 보내는 `\r`이 `\n`으로 변환되지 않음 → `handleReadData`에서 `\r\n`/`\r` → `\n` 정규화 추가

추가: pyserial과 동일하게 `O_NONBLOCK` 유지, `dup()` 대신 단일 fd 사용, 초기 device_info 응답을 동기 read로 수신 후 큐에 전달

### 핵심 설계 결정
- **pyserial 동작을 reference로**: pyserial은 `O_NONBLOCK` 유지 + `VMIN=0,VTIME=0` 설정. Swift 코드도 이를 따라야 ESP32와 정상 통신 가능
- **Actor isolation 우회**: Swift actor의 cooperative scheduling이 시리얼 read 처리량을 감당 못함. `nonisolated(unsafe)` + `NSLock`으로 thread-safe queue 구성

---

## 2026-04-02 — Codex Web Auth Status Surface in Bridge + Apple Monitor

### 문제
Codex / ChatGPT 웹 인증으로 Codex를 쓰는 경우, 공식 OpenAI usage/cost API는 web auth token을 받지 않아 실시간 리밋 게이지를 직접 표시할 수 없었다. 대신 앱에서는 해당 계정이 실제로 연결돼 있는지, 어떤 플랜인지조차 보이지 않았다.

### 해결
- `bridge/src/codex-auth.ts`: `~/.codex/auth.json`을 읽어 `auth_mode`, `last_refresh`, JWT payload의 `chatgpt_plan_type`, `chatgpt_account_id`, `chatgpt_subscription_active_until`을 추출
- `shared/src/protocol.ts` / `bridge/src/usage-event.ts`: `usage_update`에 `codexAuthMode`, `codexWebAuthConnected`, `codexPlanType`, `codexAccountId`, `codexSubscriptionActiveUntil`, `codexLastRefreshAt` 필드 추가
- `bridge/src/bridge-core.ts`: usage broadcast 시 Codex web-auth 메타데이터를 함께 실어 나르도록 연결
- `apple/AgentDeck/Model/*.swift`, `AgentStateHolder.swift`: 새 usage 필드를 상태에 반영
- `TankStatusPanel.swift`: `Codex Web` 연결 점, `Plan`, `Until` 표시 추가

### 핵심 설계 결정
- **실시간 usage와 web-auth 상태를 분리**: ChatGPT/Codex web auth는 공식 usage endpoint를 호출할 수 없으므로, 리밋 수치가 아니라 계정 상태를 따로 노출
- **JWT payload best-effort 파싱**: auth.json top-level 값이 비어 있어도 access/id token payload에서 plan/account/subscription 메타데이터를 복구

### 검증
- `pnpm --filter @agentdeck/plugin typecheck`
- 결과: 통과
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS ...`
- 결과: Swift 쪽 신규 필드 컴파일은 진행됐고, 최종 실패는 DerivedData dependency file 생성 환경 문제

### 후속 수정
- `~/.codex/auth.json`의 `chatgpt_plan_type`, `chatgpt_subscription_active_until`, `chatgpt_account_id`는 JWT payload 최상위가 아니라 `https://api.openai.com/auth` namespace 안에 들어 있었다.
- `bridge/src/codex-auth.ts`와 `apple/AgentDeck/Daemon/Core/UsageAPIClient.swift`가 이 중첩 claim을 읽지 못해, 실제 Plus/Pro 계정이어도 UI에서 plan/until이 비어 보일 수 있었다.
- 중첩 namespace 파싱을 추가해 `ChatGPT Plus/Pro`와 `Until`이 정상적으로 복구되도록 수정했다.

### 추가 검증
- `pnpm --filter @agentdeck/shared typecheck`
- `pnpm --filter @agentdeck/bridge typecheck`
- `pnpm --filter @agentdeck/plugin typecheck`
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataVerify build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

### UI 재구성 후속
- `TankStatusPanel.swift`를 `OpenClaw / OLLAMA / MLX / Subscriptions` 섹션 구조로 재구성
- `ChatGPT Plus · 2026-04-19`처럼 플랜과 만료일을 한 줄로 표시하도록 변경
- `OAuth` 점 라벨은 제거하고, 구독형 인증 서비스는 `subscriptions` 배열로 별도 노출
- `MLX`는 `http://127.0.0.1:8800/v1/models` probe를 추가해 모델 목록을 수집
- `Ollama`는 `/api/ps` 기준으로 현재 실제 구동중인 모델 목록을 우선 노출
- Android monitor / e-ink status panel도 같은 섹션 구조로 맞춤

### 비고
- Claude 쪽은 현재 daemon이 정확한 플랜명(`Claude Max`)을 항상 보장하지 못해 우선 `Claude Subscription`으로 노출
- ChatGPT는 web auth JWT의 OpenAI auth namespace에서 정확한 plan/until을 복구

---

## 2026-04-01 — Plugin v4 Cleanup + Session-Slot UI Overhaul

### 문제
1. v3 keypad actions (5개) 가 manifest에 남아 SD 앱 action list에 불필요 항목 노출
2. Session-slot 버튼의 agent watermark 거의 안 보임 (opacity 0.06)
3. OpenClaw 세션이 Claude Code와 동일한 상태 라벨/색상 사용
4. ESC/STOP 버튼이 IDLE 시 완전히 사라짐
5. No-daemon 시 "Empty" 표시 버그 (willAppear에서 daemonConnected 미체크)
6. 플러그인 아이콘이 컬러 앱 아이콘 — SD 컨벤션(투명+흰색 모노크롬)과 불일치

### 해결
1. v3 actions 5개 + expanded-actions + LayoutManager 클래스 + 14 PNGs + PI html 삭제 (manifest 10→5 actions)
2. `dimColor()` 방식 — 색상 50% 어둡게 + opacity 3배 → 선명하면서 텍스트 비침
3. OpenClaw: IDLE→STANDBY(cyan), PROCESSING→ROUTING(green), 중복 라벨 제거
4. ESC/STOP slot 4에 항상 표시 (active=bright, idle=dimmed)
5. willAppear에서 `daemonConnected` 체크 추가, slot 0에 ▶ START 버튼
6. rsvg-convert로 투명배경+흰색 terrarium SVG 아이콘 생성

### 핵심 설계 결정
- **Detail view 2×4 grid**: 0=BACK, 4=ESC/STOP (아래), 1=INFO, 2/3/5/6=content, 7=pagination
- **OpenClaw presets**: STATUS(send_prompt), MODEL(dynamic icon+switch animation), GATEWAY(browser)
- **Model switch feedback**: `startModelSwitch()` → loading icon → `checkModelSwitchDone()` (modelName 변경 감지 or 12s timeout)
- **SD MCP 평가**: `@elgato/mcp-server`는 "AI가 미리 배치된 버튼 트리거"만 가능. 우리 동적 SVG/인코더 LCD 대비 이점 없음 → 통합 불요

---

## 2026-04-01 — TC001 Phantom Octopus + ESP32 Serial Gateway Relay Bug

### 문제
1. TC001에 OpenClaw만 실행 중인데 유령 문어 표시
2. 모든 ESP32 기기에서 가재(OpenClaw) 미표시 — Pixoo/D200H와 불일치

### 해결
1. `matrix_pages.cpp` renderAgents(): `octoCount==0 && gatewayAvail` 시 fallback 문어 스킵. 미연결 시 가재도 숨김 (`connected && gatewayAvail` 체크)
2. `ESP32Serial.swift` prepareForSerial(): `gatewayAvailable`/`gatewayHasError`를 state_update에서 삭제하고 있었음 → 유지하도록 수정. `sessions_list`에서 `alive`/`id` 필드 누락도 수정

### 핵심 설계 결정
- **시리얼 페이로드 최적화 시 기능 필수 필드 삭제 주의**: ESP32Serial이 시리얼 크기 최적화를 위해 필드를 삭제할 때, 렌더링에 필수인 `gatewayAvailable`까지 삭제. 최적화 대상 필드를 화이트리스트가 아닌 블랙리스트로 관리해야 이런 사고 방지
- **TC001 USAGE 페이지 Pixoo HUD 통일**: "5H"/"7D" 라벨 → 퍼센트 숫자(게이지색) + 리셋 시간(뮤트 그레이). 글리프도 Pixoo `PIXEL_FONT` (filled/blocky) 스타일로 교체 — LED 매트릭스에서 가독성 향상

---

## 2026-04-01 — Swift Daemon Gateway State Machine Idle Recovery

### 문제
Swift daemon이 OpenClaw Gateway에 WebSocket 연결은 성공하지만 `/health`에서 `state: "disconnected"`로 표시됨. Gateway 프로세스는 정상 동작 중.

### 원인 분석
1. **핵심**: `handleGatewayEvent`의 `gateway_chat` 핸들러가 payload의 `state` 필드(delta/final/aborted/error)를 구분하지 않고 모든 chat 이벤트에 `spinner_start`만 트리거. Node.js bridge는 `final`/`aborted`/`error` 시 `idle` 이벤트를 emit하여 SM을 processing → idle로 복귀시킴.
2. **연쇄**: chat이 processing에 고착 → WS 일시 끊김 → probe가 disconnectGatewayAdapter() 호출 → SM: disconnected → 재연결 시 핸드셰이크 타이밍에 따라 idle 복구 실패 가능
3. **부수**: `onConnectionChanged(false)` 핸들러가 SM을 전환하지 않아 상태 불일치 발생. `gateway.connected`가 adapter 존재 여부만 체크하여 실제 WS 인증 상태 미반영.

### 해결
- chat state 분기: delta→processing, final/aborted/error→idle
- approval resolved 이벤트 전파 + SM 전환
- WS disconnect 시 SM → disconnected 전환 추가
- health의 gateway.connected를 실제 WS isConnected 상태 반영

### 핵심 설계 결정
- Swift daemon의 Gateway 이벤트 처리는 Node.js bridge의 `openclaw.ts` 어댑터 로직과 1:1 대응해야 함. SM 전환 누락 시 상태 드리프트가 누적되어 진단이 어려워짐.
- Actor의 `isConnectedSnapshot`을 async getter로 노출하여 health endpoint에서 안전하게 조회.

---

## 2026-04-01 — Plugin V4 Recovery and Daemon Startup Race Fixes

- Fixed Stream Deck v4 `START` button behavior to stop launching the CLI daemon.
  - The button now tries to open the installed `AgentDeck` macOS app.
  - If the app is not installed, it opens the GitHub repository page instead.
- Restored detail-view usability for interactive prompts with more than four options.
  - Added paging for detail-view option slots using slot 7 as a `NEXT` pager when needed.
  - Option selection now preserves the original prompt option index across pages.
- Hardened model-switch UI recovery in the v4 plugin.
  - Added timeout/state-based cleanup so the `MODEL` preset does not remain stuck in loading forever.
  - `prompt_options` now refreshes detail view state immediately, not only `state_update`.
- Fixed a daemon startup race in the macOS app lifecycle.
  - When `DaemonServer` reports `alreadyRunning(port:)` during local startup, `DaemonService` now retries health probing for a short period before declaring the registry stale.
  - This avoids false-negative external-daemon detection during normal startup overlap.

## 2026-04-03 — Dashboard Engine Sections Initial Sync Hardening

### 문제
macOS Dashboard의 `OpenClaw / OLLAMA / MLX / Subscriptions` 섹션이 의도한 구조로 렌더되더라도, 초기 연결 시점에는 일부 섹션이 비어 보일 수 있었다. 원인은 엔진 상태가 서로 다른 이벤트 경로에 흩어져 있었기 때문이다.

- `modelCatalog`는 주로 `state_update`에만 실려 `OpenClaw` 섹션이 빈 채 남을 수 있었음
- `OLLAMA / MLX / Subscriptions`는 `usage_update` 중심이라 probe가 이미 끝났더라도 UI 반영이 늦을 수 있었음
- Gateway model catalog / Ollama / MLX probe 값이 바뀌어도 즉시 브로드캐스트되지 않는 경로가 있었음

### 해결
- `DaemonServer.swift`
  - `modelCatalog + ollamaStatus + mlxModels + subscriptions`를 공통 엔진 상태 스냅샷으로 묶어 `state_update`와 `usage_update` 양쪽에 모두 포함
  - Gateway `model_catalog` 수신 시 `broadcastStateUpdate()`와 함께 `broadcastUsage()`도 수행
  - sibling relay를 통한 model catalog merge 시에도 즉시 두 이벤트를 모두 재브로드캐스트
  - Gateway disconnect로 model catalog가 비워질 때도 상태/usage를 같이 갱신
  - Ollama / MLX probe 결과가 이전 값과 달라지면 즉시 상태/usage를 같이 브로드캐스트
- `Protocol.swift` / `shared/src/protocol.ts` / Android `Protocol.kt`
  - `UsageEvent`/`UsageUpdate`에 `modelCatalog` 추가
  - `StateUpdateEvent`/`StateUpdate`에 `mlxModels`, `subscriptions` 추가
- `AgentStateHolder.swift` / Android `AgentState.kt`
  - `state_update`와 `usage_update` 어느 쪽으로 오더라도 engine section 데이터가 상태에 반영되도록 처리

### 검증
- `pnpm --filter @agentdeck/shared typecheck`
- 결과: 통과
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataDashboardSync build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

## 2026-04-03 — OpenClaw Display Compaction + MLX nanoLLaVA Filter

### 문제
- OpenClaw 모델 목록이 raw 카탈로그 이름을 거의 그대로 보여줘서 `DeepSeek: DeepSeek V3.2` 같은 중복 접두사가 그대로 보였다.
- 모델 family를 묶더라도 `GLM:` 같은 그룹 라벨을 별도로 붙이는 방식은 오히려 UI를 지저분하게 만들 수 있었다.
- MLX probe는 현재 서버가 노출하는 모든 모델을 그대로 보여줘 `nanoLLaVA` 같은 보조 비전 모델까지 Dashboard에 올라왔다.

### 해결
- `TankStatusPanel.swift` / Android `EnginePanel.kt`
  - OpenClaw 모델명을 family 기준으로 compact display 하도록 조정
  - 별도 그룹 라벨은 붙이지 않고 `GLM-5.1, 5 Turbo, 5, 4.7`처럼 접두사를 한 번만 보이게 압축
  - `DeepSeek: DeepSeek ...` 같은 중복 접두사는 정규화
  - OpenClaw 첫 줄(대표 모델이 우선 정렬되는 줄)은 약간 다른 색상으로 강조
- `DaemonServer.swift` / `bridge/src/mlx-probe.ts`
  - MLX model probe 결과에서 `nanoLLaVA`는 기본 Dashboard 목록에서 제외

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataOpenClawGrouping build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

## 2026-04-03 — Pixoo Log Noise Reduction

### 문제
- Pixoo render loop가 약 3 FPS로 돌아가면서 성공 push 로그를 매 tick마다 남겨, 실제 장애나 상태 전이 로그가 묻혔다.
- `Push OK -> ... picId=...`가 계속 반복되어 디버깅 가독성이 떨어졌다.

### 해결
- `PixooModule.swift`
  - 성공 push는 매번 찍지 않고 첫 성공, 복구 직후, 그리고 일정 주기 요약만 남기도록 조정
  - 실패는 첫 실패 / 5회 / 20회 단위와 실패 사유 변경 시만 에러 로그로 남기도록 압축
  - push loop 내부 HTTP 실패 로그는 중복 출력되지 않도록 억제
  - 복구 시 `Pixoo recovered on ... after N failed push(es)` 형태로 상태 전이를 명확히 기록

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataPixooLogs build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

## 2026-04-03 — Common Log Suppression for Steady-State Noise

### 문제
- Pixoo뿐 아니라 ESP32 serial, usage relay 같은 steady-state 경로도 반복 debug 로그가 많아 실제 장애 로그가 묻혔다.
- 정상 상태를 매번 출력하는 대신, 상태 변화와 반복 실패 요약이 더 중요했다.

### 해결
- `DaemonLogger.swift`
  - `throttledDebug(category:key:message:minInterval:)` 추가
  - `sampledDebug(category:key:every:message:)` 추가
- `ESP32Serial.swift`
  - 반복되는 open 실패 / read exit / incoming message type 로그를 suppression 정책으로 전환
  - serial open 성공은 debug가 아니라 명확한 상태 전이로 `info`로 남김
- `DaemonServer.swift`
  - usage relay 시작 / per-port relay / tier1 failure / tier3 fallback 로그를 샘플링 또는 throttling 적용

### 검증
- `pnpm --filter @agentdeck/bridge typecheck`
- 결과: 통과
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataLogPolicy build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`

## 2026-04-03 — Creature Layout and Swim Lane Stabilization

### 문제
- 다중 세션일 때 크리처가 바닥 위에서 자연스럽게 줄지어 서지 못하고, 격자형 슬롯 때문에 서로 겹치거나 HUD 패널과 과도하게 충돌했다.
- WORKING 상태의 waypoint가 전역 swim bounds만 기준으로 잡혀 여러 크리처가 한쪽으로 몰리거나, 수면/지면 경계 가까이 과하게 움직였다.
- Pixoo 렌더러는 별도 golden-ratio X 배치를 써서 일반 terrarium과 다른 밀집 패턴을 보였다.

### 해결
- `CreatureLayout.swift`, `CreatureLayout.kt`
  - octopus / cloud / opencode 공통 `layoutBand` 슬롯 생성기로 교체
  - 한 줄 또는 2~3줄 staggered band로 배치해 자연스럽게 나란히 서도록 조정
  - 왼쪽 세션 패널과 오른쪽 상태 패널을 덜 침범하도록 X 범위를 더 보수적으로 조정
- `OctopusCreature.swift`, `OctopusCreature.kt`
  - WORKING 상태 waypoint를 전역이 아니라 creature별 local swim lane 안에서만 선택하도록 변경
  - current position clamp도 local lane 기준으로 조정
- `JellyfishCreature.swift`, `OpenCodeCreature.swift`
  - idle / waiting / pulsing 높이를 home slot 기준으로 따라가게 조정
  - 과도한 좌우 drift를 줄이고 home 주변의 좁은 범위로 제한
- `CloudCreature.kt`, `OpenCodeCreature.kt`
  - Android 쪽도 동일하게 local swim lane 개념 반영
- `PixooRenderer.swift`
  - golden-ratio 임의 X 배치를 제거하고 creature type별 공통 슬롯 레이아웃 사용
  - state별 Y만 별도 보정하되 slot 기반 분산을 유지

### 검증
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataCreatureLayout build CODE_SIGNING_ALLOWED=NO`
- 결과: `BUILD SUCCEEDED`
- `./gradlew :app:compileDebugKotlin`
- 결과: 이번 변경분 에러는 해소됨. 현재 남은 실패는 기존 `EinkRenderer.kt`의 `PathParser`/delegate 관련 선행 오류뿐

## 2026-04-04 — Sleep/Wake Recovery 개선

### 문제
Mac 화면 끄고 다시 켰을 때 여러 디바이스/클라이언트 복구 실패:
1. Apple/Android 대시보드에 5h !, 7d ! stale 표시 — usage 갱신 안 됨
2. D200H 울란지 기본 화면 — HID 연결 미복구
3. ESP32 3종 화면 꺼짐 — display_state 미전송
4. TC001 SEARCHING↔agent 깜빡임 — USB 불안정 + 모듈 wake 순차 블록

### 해결
1. **Usage fetch on wake**: Node.js + Swift 양쪽 wake handler에 4초 후 usage fetch 추가. `resetConsecutiveFailures()` 추가 — sleep 전 backoff이 wake 후까지 지속되는 문제 해결
2. **D200H IOHIDManager 전략 전환**: `unschedule/re-schedule`은 이미 매칭된 디바이스에 callback을 다시 발생시키지 않음 → `stop()+start()`로 IOHIDManager 완전 재생성. 미연결시 1회 retry
3. **display_state in initialStateProvider**: ESP32 serial reconnect 시 state_update + usage_update만 보내고 display_state 누락 → 추가
4. **ESP32 2초 USB 안정화 대기**: handleESP32Wake에서 stale 연결 닫고 즉시 re-poll → 2초 대기 후 re-poll
5. **ModuleManager.wakeAll() 병렬화**: `for await` → `withTaskGroup` — D200H의 8초 wake가 serial/pixoo를 블록하여 이중 연결 발생하는 문제 해결

### 핵심 설계 결정
- **IOKit matching callback**: `IOHIDManagerScheduleWithRunLoop` re-schedule로는 already-present 디바이스 callback이 재발생하지 않음. 반드시 IOHIDManager를 파괴하고 새로 생성해야 함
- **Module wake 병렬화**: 디바이스 모듈은 서로 독립적이므로 병렬 wake가 안전. D200H의 긴 wake 시간이 다른 모듈을 블록하는 것은 불필요한 지연
- **Usage backoff reset on wake**: sleep 시간은 항상 Retry-After 기간보다 길므로, wake 시 backoff 초기화가 안전

## 2026-04-05 — macOS Daemon 포트 바인딩 안정성

### 문제
Mac 화면이 꺼졌다 돌아오면 NWListener가 `Address already in use`로 port 9120 바인딩에 실패. Daemon 프로세스는 살아있지만 WS 서버가 없는 좀비 상태가 되어 모든 클라이언트 연결 불가. 10초 health monitor 감지 후에야 재시작. Node.js CLI daemon에서는 없었던 macOS 앱 전용 버그.

로그 증거:
```
nw_listener_socket_inbox_create_socket bind(409, ::.9120) tcp, ... server failed [48: Address already in use]
Server listener failed: POSIXErrorCode(rawValue: 48): Address already in use
... 10초 후 ...
Local daemon on port 9120 is no longer healthy — restarting in-process daemon
```

### 해결
1. **`NWParameters.allowLocalEndpointReuse = true`** — SO_REUSEADDR 등가. Node.js `http.createServer()` 기본 동작과 일치하여 TIME_WAIT 포트 즉시 재바인딩 가능
2. **Listener `.failed` 상태 콜백 전파** — `WebSocketServer.onListenerFailed` 추가, `DaemonService`에서 수신 후 1s/2s/4s 백오프로 최대 3회 재시도 (10초 health monitor 대기 없이 즉시)
3. **`isPortFree` dual-stack 테스트** — `AF_INET6` + `IPV6_V6ONLY=0` + `::` wildcard로 NWListener가 실제 바인딩하는 주소와 일치 (기존: IPv4 127.0.0.1 전용 테스트)
4. **Network path update IP 변경 시에만 `wakeAll()`** — 화면 꺼짐 중 WiFi flicker로 IP 미변경 path update가 반복되어 module churn 유발. IP 동일 시 timeline relay sync만 실행 (경량)

### 핵심 설계 결정
- **NWListener 기본 바인딩 주소는 `::` (IPv6 wildcard)**. 포트 체크 시 반드시 동일 주소로 테스트해야 정확. IPv4 loopback만 테스트하면 false positive 발생
- **NWListener `.failed` 상태는 반드시 외부로 전파**. 단순 로그만으로는 daemon이 좀비 상태가 됨
- **Network path update는 IP 변경과 분리**. WiFi flicker/VPN 상태 변경은 일상적이고 module 재시작은 비용이 큼. IP 실제 변경일 때만 full wake
- **Node.js `http.createServer()`는 기본 SO_REUSEADDR=true + IPv4 0.0.0.0 wildcard**. Network.framework NWListener는 둘 다 명시 필요

## 2026-04-11 - macOS Dashboard 상태/모델 카탈로그 복구

### 문제
- Mac 화면만 꺼졌다가 돌아온 뒤 macOS Dashboard App의 상태/모델 목록이 정상적으로 갱신되지 않음
- `/usage` 모델 카탈로그와 Dashboard/Menu Bar/D200H 노출 순서가 경로마다 달라질 수 있음
- 검증 중 macOS 테스트 타깃 링크와 signed debug build 번들링도 함께 깨져 있음

### Root Cause
1. macOS sandbox entitlement에 `/.openclaw/`가 없어 `openclaw models list --json`가 `~/.openclaw/.../models.json.*.tmp`를 쓰다가 `EPERM`으로 실패할 수 있었다. 이 경로가 실패하면 Gateway는 살아 있어도 모델 카탈로그가 비거나 stale 상태로 남는다.
2. 사용자의 설정처럼 실제 sleep이 아니라 display-off만 발생하면 `scenePhase` foreground 복구가 트리거되지 않을 수 있었다. 이 경우 WebSocket은 connected처럼 보이지만 데이터 수신이 멈춘 stale 상태가 지속될 수 있다.
3. 세션/모델 정렬 규칙이 공통화되어 있지 않았다. `refreshSessions()`의 task group 완료 순서, virtual OpenClaw session 삽입 위치, UI별 모델 표시 규칙이 서로 달라 기기마다 순서가 달라질 수 있었다.
4. signed build에서 `agentdeck-runtime`을 `Contents/Helpers` 아래에 넣어 codesign이 JS/source map까지 nested code처럼 검사했다. pnpm `.ignored_*` broken symlink도 helper runtime rsync를 깨뜨릴 수 있었다.
5. `AgentDeckTests_macOS`의 `TEST_HOST`가 실제 산출물 `AgentDeck.app/Contents/MacOS/AgentDeck`가 아니라 `AgentDeck_macOS.app/Contents/MacOS/AgentDeck_macOS`를 가리켰다.

### 해결
- `AgentDeck.entitlements`와 `project.yml`에 `/.openclaw/` read-write exception 추가. XcodeGen 재생성 시 누락되지 않도록 기존 `/.codex/`, `/Library/pnpm/`, USB entitlement도 `project.yml`에 동기화
- `DashboardDataRules`를 추가해 세션 정렬, 모델 카탈로그 canonicalize/merge/sort, OpenClaw 표시 라인 생성을 공유 규칙으로 통합
- `DaemonServer`의 session list/model catalog 경로와 macOS Dashboard/Menu Bar/Tank UI가 동일한 공유 규칙을 사용하도록 변경
- macOS `AgentStateHolder`에 stale-data watchdog 추가: bridge는 connected인데 일정 시간 데이터가 안 오면 preferred local bridge로 강제 재연결
- `OpenClawAdapter.emitModelCatalog()`에 빈 카탈로그 1회 지연 재시도 추가
- `agentdeck-runtime` 번들 위치를 `Contents/Helpers`에서 `Contents/Resources/agentdeck-runtime`으로 이동하고 `.ignored_*` rsync exclude 추가
- `AgentDeckTests_macOS`의 `TEST_HOST`/`BUNDLE_LOADER`를 실제 macOS 앱 산출물 경로로 수정

### 검증
- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataDashboardSync CODE_SIGNING_ALLOWED=NO` - 성공
- `xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS'` - signed debug build 성공. 다만 현재 로컬 debug signing 환경에서 직접 launch는 곧 clean shutdown되어, 실행 검증은 다시 unsigned debug build로 복구해 진행
- `xcodebuild build -project apple/AgentDeck.xcodeproj -target AgentDeckTests_macOS -configuration Debug -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO` - 성공
- `xcodebuild test -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO` - 스킴에 test action이 없어 실행 불가. `xcrun xctest` 직접 실행도 app-hosted test bundle 특성상 앱 심볼을 로드하지 못해 실행 불가
- Runtime `/health`, `/status`, `/usage` 모두 200 OK. `/status` 세션 순서는 OpenClaw, Codex, OpenCode, daemon으로 안정화됨
- Runtime `/usage` 모델 카탈로그는 7개 반환: default, fallback, configured 순서 유지
- `~/.agentdeck/swift-daemon.log` 최신 로그: `2026-04-11T01:01:36Z DEBUG [Daemon] Model catalog updated from gateway: 7 models`. 최신 구간에서 `EPERM`/empty retry 재발 없음

---

## 2026-04-11 — D200H Helper Promotion 실패 시 Local Daemon 복구

### 문제
`startBundledD200HHelper()`가 `stop()` 호출 후 helper 프로세스를 spawn했는데, helper가 6초(20×300ms) 내에 health 응답하지 못하면 helper만 종료하고 끝남. local in-process daemon은 이미 stop된 상태로 남아 dashboard/CLI/D200H 모두 끊김. 사용자가 Settings에서 수동으로 재시작할 때까지 앱 전체가 "daemon down" 상태.

### 해결
`DaemonService.startBundledD200HHelper()` 의 20-probe fail 경로에서 `stopOwnedExternalDaemonIfNeeded()` 직후 `start()` 재호출. `d200hHelperPromotionAttempted` 플래그는 entry 시점에 이미 true로 설정되어 있어 health monitor가 즉시 같은 promotion을 다시 시도하지 않음.

errorMessage는 `"... Reverted to local daemon."` 으로 명시해 사용자가 진단 가능하도록.

### 핵심 설계 결정
- **stop() 후 spawn 실패 = local daemon 복구 의무**: helper promotion은 "최선의 노력" 경로이지 fail-stop이 아님. fall-through fallback이 항상 local daemon이어야 함.
- **무한 promotion 방지**: 기존 `d200hHelperPromotionAttempted` 플래그 유지로 충분. `restart()` 시점에만 false로 리셋되므로 사용자 의도가 명시적으로 표현될 때까지 1회만 시도.

## 2026-04-12 — D200H Stock-HID Safe Layout

### 문제
D200H를 Stream Deck처럼 per-key 동적 화면으로 다루면 아이콘 캐시, partial update, 펌웨어 manifest 해석 차이 때문에 실제 기기에서 2번 셀에 엉뚱한 아이콘이 남거나 13L/13R 병합 영역이 왜곡되어 보였다. 특히 병합 영역을 두 개의 일반 버튼처럼 제어하면 stock firmware의 `smallwindow` 처리와 충돌해 한 버튼 크기 이미지가 가로로 늘어난 형태가 발생할 수 있었다.

### 해결
- D200H는 stock HID 안정 경로로 고정: partial update, press flash, animation을 비활성화하고 전체 `set_buttons` 패킷만 사용
- 아이콘 파일명에 content hash를 포함해 기기/펌웨어 쪽 stale bitmap cache 충돌을 회피
- 13L/13R 병합 영역은 `3_2`의 `com.ulanzi.ulanzideck.smallwindow.window` manifest entry 하나만 사용하고, `4_2` entry는 만들지 않음
- 병합 영역 이미지는 버튼 한 개 PNG를 늘리지 않고 `392x196` wide PNG로 직접 렌더링
- 실제 버튼 가장자리/마스크에서 텍스트가 잘리지 않도록 D200H 렌더러의 safe inset을 키우고 텍스트/상태 표시를 안쪽으로 이동
- `tools/creature-simulator/index.html`의 D200H 미리보기도 Stream Deck 복제가 아니라 동일 UX 의미를 유지하는 D200H-native stock-HID-safe 디자인으로 분리
- `stock-safe-v2` renderer revision을 D200H state hash에 포함해 렌더러만 바뀐 경우에도 다음 빌드/refresh에서 새 payload가 확실히 전송되도록 함
- Stream Deck session/detail button의 agent watermark opacity를 올리고 simulator의 Stream Deck/D200H 미리보기를 같은 방향으로 조정

### 핵심 설계 결정
- **UX 의미는 Stream Deck과 맞추되, 시각 디자인은 D200H 전용으로 둔다.** D200H는 Stream Deck SDK/화면 모델이 아니라 stock firmware manifest와 HID zip 패킷의 제약을 받으므로 pixel-perfect Stream Deck 복제보다 기기 안정성이 우선이다.
- **병합 영역은 펌웨어가 기대하는 한 개 smallwindow로 취급한다.** 두 셀을 개별 아이콘처럼 관리하면 실제 표시 좌표/스케일이 불안정해진다.
- **D200H 아이콘은 물리 버튼 테두리를 신뢰하지 않는다.** 실제 표시 영역 가장자리가 살짝 묻히므로 텍스트와 상태 표시는 충분한 내부 여백 안에 배치한다.

### 검증
- `swiftc -parse apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` - 성공
- `node --check scripts/render-creature-simulator.mjs` - 성공
- `git diff --check -- apple/AgentDeck/Daemon/Modules/D200hHidModule.swift tools/creature-simulator/index.html` - 성공
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS' build` - signed debug build 성공
- Runtime `/status`: `stableStockHid=true`, `partialUpdatesEnabled=false`, `usbEntitlementPresent=true`, `managerOpened=true`, `connected=true`, `writeFail=0`
- Runtime `/d200h/refresh`: `writeOK` 114 → 156, `writeFail=0`
- 최신 D200H dump manifest: `3_2` smallwindow entry만 존재하고 `4_2` 없음. wide icon은 `392x196`
- `stock-safe-v2` signed debug relaunch 후 Runtime `/status`: `rendererRev=stock-safe-v2`, `stableStockHid=true`, `usbEntitlementPresent=true`, `managerOpened=true`, `writeFail=0`
- 같은 relaunch에서 port 9120을 별도 `agentdeck claude` session이 점유해 macOS daemon이 9121로 fallback. 이 상태에서는 D200H module은 정상 write하지만 `sessionsCount=0`이라 세션 타일 대신 usage/empty 슬롯 payload가 전송됨

## 2026-04-13 — D200H Simulator-Parity Recovery

### 문제
`D200hHidModule.swift`의 워크트리 구현이 어느 순간 이전 형태로 돌아가 있었다. 실행 중인 바이너리는 `stock-safe-v8` 계열로 동작했지만 소스에는 renderer rev, stock-HID 안정 플래그, hash 기반 icon filename, 단일 wide usage image가 빠져 있어 다음 빌드/재실행 시 `btn13L/btn13R` 분할과 partial update 경로로 회귀할 수 있었다.

또한 `open`이 번들 ID 기준 LaunchServices 캐시를 따라 오래된 DerivedData 앱을 띄우는 경우가 있어, 실제 실행 경로와 방금 빌드한 앱 경로가 어긋날 수 있었다.

### 해결
- D200H renderer revision을 `stock-safe-v9`로 올리고 `lastStateHash`에 포함
- `stableStockHid=true` 경로를 고정해 partial update, press flash, animation payload 전송을 비활성화
- `btn13L/btn13R` manifest를 제거하고 `3_2` 하나만 사용. `4_2`는 생성하지 않으며, stale action 제거를 위해 `Action: ""`을 명시
- 모든 icon filename에 `stock-safe-v9`와 FNV-1a content hash를 포함
- `http://localhost:5173/` creature simulator의 현재 D200H 디자인 기준에 맞춰 세션 버튼 safe-area를 18px 카드 구조로 조정
- usage limits는 두 개의 버튼 PNG 합성이 아니라 `392x196` wide PNG를 직접 렌더. `LIMITS`, 5H/7D 퍼센트, compact reset time, ChatGPT 구독 결제일 라인을 분리 배치
- reset time은 `120h` 같은 시간 누적 표기가 아니라 `5d`, `4d9h` 형태의 compact day/hour 표기로 조정
- virtual OpenClaw gateway 표시명은 `Gateway`가 아니라 `OpenClaw`로 유지

### 검증
- `swiftc -parse apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` - 성공
- `git diff --check -- apple/AgentDeck/Daemon/Modules/D200hHidModule.swift` - 성공
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'generic/platform=macOS' -derivedDataPath /tmp/AgentDeckDerivedDataD200HRecoveryV9 build` - 성공
- Runtime `/status` on fallback port 9124: `rendererRev=stock-safe-v9`, `stableStockHid=true`, `partialUpdatesEnabled=false`, `connected=true`, `managerOpened=true`, `writeFail=0`
- 최신 dump ZIP: `icons/btn13-wide-stock-safe-v9-*.png` 하나만 포함, `btn13L/btn13R` 없음, manifest는 `3_2`만 사용하고 `Action: ""` 포함
- `/tmp/agentdeck-d200h-v9-preview2/btn13-wide-stock-safe-v9-d6a16a3d.png` 시각 확인: 퍼센트/reset/billing line 겹침 없음

### 후속 보정
- `stock-safe-v11`: D200H 실제 버튼 마스크에서 하단 경계가 묻히지 않도록 세션 텍스트 블록과 usage billing line을 위로 이동
- OpenClaw 버튼은 크리처를 조금 키워 아래로 내리고 텍스트 블록은 위로 올려, 크리처와 텍스트 사이 공백 및 `STANDBY` 하단 경계 접근 문제를 완화
- `tools/creature-simulator/index.html`의 D200H 세션 텍스트 좌표도 같은 방향으로 업데이트
- `stock-safe-v12`: 세션 아이콘은 유지하고 usage wide button만 다시 구성. 5H/7D를 하단에 몰린 두 칸 구조 대신 중앙부의 두 full-width row로 배치하고, Apple HUD 계열에서 쓰는 green/amber/red 잔여량 색상(`#22C55E`, `#FBBF24`, `#EF4444`)에 맞춤
- `stock-safe-v13`: D200H 실제 화면에서 V12의 작은 reset/billing 텍스트와 하단 치우침이 잘 보이지 않아 usage 버튼을 정보 절약형으로 재설계. 5H/7D 큰 퍼센트와 두꺼운 bar만 중앙 safe area에 배치하고, 구독 날짜는 있으면 상단 오른쪽의 짧은 날짜로만 표시
- `stock-safe-v14`: V13의 좌우 반쪽 카드 구조가 가로로 눌려 보이고 퍼센트/게이지 간격이 애매해, 5H/7D를 세로로 쌓은 row 구조로 변경. 각 row는 왼쪽에 label+percent, 오른쪽에 bar를 두고 구독 날짜는 `PLUS APR 19`처럼 맥락 있는 짧은 라벨로 표시

---

## 2026-04-13 — APME 카테고리별 평가 아키텍처 문서화 + 테스트 복구

### 문제
1. `pnpm test`에서 APME 관련 31개 테스트 실패 — 표면상 better-sqlite3 resolution 이슈로 보였으나 실제 원인은 달랐다.
2. PTY response 캡처에서 `⏺` 마커 이후 spinner 잔해(`✢ Whirring…`, `⏸planmode`, `? for shortcuts`)가 누출되는 경우 존재.
3. APME 관련 문서가 범용화(2026-04-13 1차) 이전 상태에 머물러 최신 아키텍처(카테고리별 평가 분화, turn-level mid-session eval, 멀티 에이전트 지원, composite score)가 반영되지 않음.

### 해결

**1. Rubric PK 충돌 버그 (31개 테스트 복구)**

근본 원인: `rubrics.version INTEGER PRIMARY KEY`인데 `seedDefaultRubric()`이 general 루브릭을 `version=1`로 INSERT한 후 CATEGORY_RUBRICS 6종도 모두 `version=1`로 INSERT하려 해서 UNIQUE constraint 위반. 예외가 `init()` 외부 try-catch에 삼켜져서 store는 disabled로 남았고, 테스트에서는 `store.enabled=false` 상태로 collector/tuner가 모두 null 접근 오류.

Fix: category rubric INSERT 시 `version` 컬럼을 생략하여 SQLite rowid auto-assign 사용 (store.ts:422-438).

디버깅 경로: store.init()의 외부 catch가 에러를 삼키고 debug 채널에만 남기고 있어 "better-sqlite3 not installed" 케이스와 구분 불가. 일시적으로 `console.error('[APME init]', err)`를 추가해서 실제 스택(`UNIQUE constraint failed: rubrics.version`)을 확인한 뒤 원인 도달.

**2. PTY response 필터 강화 (index.ts:517-527, 1217-1233)**

- spinner char 세트를 output-parser.ts의 `SPINNER_CHARS` (`✢✳✶✻✽`)와 완전 일치시킴 (`⏵`, `>` 포함)
- `accept\s*edits`, `plan\s*mode`, `? for shortcuts` 상태 텍스트 감지 추가
- Codex secondary path(line 1217)에 동일한 필터 로직 적용 (기존에는 무필터 `slice(-5)`)

**3. APME 문서 정비 (4개 신규/재작성)**

- `docs/why-apme.md` (신규) — 6개 모델 감 기반 라우팅 문제, 카테고리별 평가 전략의 설계 의도, composite score 설계 근거, vibe-first 원칙
- `docs/apme-pipeline.md` (신규) — 8레이어 파이프라인(L1 3-path ingestion → L8 device rendering) 심층 해설 + file:line 앵커 인덱스
- `docs/posts/evaluation-over-vibes.md` (신규) — 블로그 포스트
- `docs/apme.md` (재작성) — 카테고리별 평가 방법 분화, turn-level judge, 멀티 에이전트 `wireAgentApme`, composite score, outcome taxonomy, daemon 30s 복구 루프, device broadcast, 로컬 전용 judge (MLX + OpenClaw Gateway)
- `README.md` APME 섹션 전면 재작성 + CLI 서브커맨드 표 확장
- `CLAUDE.md` Documentation Index에 신규 2개 문서 등록

**4. 테스트 보강**

`apme-collector.test.ts` — multi-turn 사이클 + `setLastClosedTurnResponse` fallback 테스트 2개 추가. OpenClaw/OpenCode가 사용하는 `wireAgentApme` 경로(prompt→response→close) 핵심 로직 커버. 최종 884/884 통과.

### 핵심 설계 결정
- **카테고리별로 평가 방법이 다르다** — 코딩 3종(coding/refactoring/debugging)은 run-level + 결정론 레이어(lint/build/tests) + LLM judge, 비코딩 4종(conversation/planning/research/review)은 turn-level + judge only. 비코딩에서 git diff는 의미 없으므로 응답 캡처 여부만으로 `committed` 판정.
- **Composite score는 4차원 가중합** — 0.40 × outcome + 0.40 × judge + 0.15 × efficiency + 0.05 × vibe. 단일 신호(judge만)로는 불안정하므로 독립 차원을 합쳐 노이즈 상쇄.
- **Judge는 로컬 전용** — MLX(Qwen3.5-30B) 기본, OpenClaw Gateway 보조. `sampleRate: 1.0`이 기본(전수 평가)이 가능한 이유. 클라우드 API 백엔드는 문서에서 언급하지 않기로 결정(사용 계획 없음).
- **Vibe 레이블링이 ground truth, judge는 그걸 따라간다** — Stage 3(vibe) 데이터 없이 Stage 4(auto-tune)는 무의미. 현재 병목은 인프라가 아니라 사용자의 레이블링 습관이며, 그래서 `eval_result` 를 모든 디바이스에 timeline entry로 브로드캐스트(Stream Deck/Apple/Android/ESP32/TUI)해서 시선에 강제로 들어오게 함.
- **에러 로깅 관례** — `init()` 같은 critical path에서 catch만 하고 debug 채널에만 남기면 원인 추적이 지연된다. Critical path 실패는 최소한 1회성 console.error로 스택 남기는 걸 검토.

---

## 2026-04-21 — OpenClaw Gateway 공유 토큰 인증 디버깅

### 문제
macOS App(App Store 빌드)에서 Settings → OpenClaw Gateway Advanced에 토큰을 저장해도 인증이 안 되는 문제. 순서대로 발생한 오류:
1. `gateway_token_missing` — 토큰이 아예 전송 안 됨
2. `device_auth_invalid` — 토큰 전송 후 디바이스 서명 거부, Web UI에 승인 항목도 안 뜸
3. 연결은 됐지만 OpenClaw 세션/액션 정보가 안 나옴

### 해결

**원인 1: `pairingRequired` 과도 적용 → 재연결 영구 차단**

`gateway_token_missing` 에러에도 `pairingRequired = true`가 설정되어 어댑터가 재연결을 영구 차단했다. `handleResponse`에서 Web UI 조치가 필요한 상태(`pairing_required`, `device_auth_invalid`, `approval_pending`, `unsupported_protocol`)에만 `pairingRequired = true`를 설정하도록 범위 축소.

**원인 2: 공유 토큰 모드에서 device auth를 함께 전송 → DEVICE_AUTH_INVALID**

Gateway shared-token 모드는 device 승인이 불필요한데 device auth(Ed25519)를 같이 보내면 Gateway가 미등록 디바이스로 거부한다. `sendConnectRequest`에서 device auth 전송 조건을 변경:
- device auth token이 있을 때(이전에 페어링됨) → 전송
- shared token만 있을 때(최초 연결) → 전송 안 함
- 아무 토큰 없을 때(device-pairing 모드) → 전송

**원인 3: `sessions.subscribe` 실패 시 무음 처리**

`sessions.subscribe` 실패 → `sessions.changed` 이벤트 미수신 → 새 세션 시작을 감지 못 함. 대응:
- 모든 non-connect RPC 실패에 에러 로깅 추가
- `sessions.subscribe` 응답이 `subscribed: false`면 15초 폴링 fallback 자동 시작

**부수 수정**

- DaemonServer: 세션 resurrection(daemon 재시작 중 hook 이벤트 수신 시 세션 자동 생성), 자가 probe 스킵
- IntegrationsView: `token_mismatch`("잘못된 토큰 붙여넣기")와 `device_auth_invalid`("Web UI 승인 필요") 메시지 분리. `device_auth_invalid`는 첫 연결 시 정상 흐름이므로 빨간 "Auth failed" → 주황 "Awaiting"으로 변경
- TopologyRail/IntegrationsView: Claude row를 hooks OR OAuth 둘 중 하나만 활성이어도 "Connected"로 표시

### 핵심 설계 결정

**OpenClaw Gateway auth 모드 2가지 (Swift 어댑터 기준)**

| 모드 | 조건 | connect params |
|------|------|----------------|
| Shared-token 전용 | `sharedToken != nil && deviceToken == nil` | `auth.token = sharedToken` only |
| Device-pairing 전용 | `sharedToken == nil` | `device` auth (empty device token in sig) → Gateway issues pairing request |
| 페어링 완료 재연결 | `deviceToken != nil` | `device` auth + `auth.deviceToken` |

**pairingRequired = true 적용 기준**

`pairing_required`, `device_auth_invalid`, `approval_pending`, `unsupported_protocol` → reconnect 차단. `gateway_token_missing`, `token_mismatch` → 차단 안 함(사용자가 Settings에서 수정 가능한 설정 오류).

**세션 subscription fallback**

Gateway가 `sessions.subscribe`를 지원하지 않거나 권한 부족으로 실패하면 15초 폴링으로 자동 전환. `sessionsSubscribed` 플래그로 추적.

---

## 2026-04-22 — OpenClaw Gateway 타임라인 × APME 연동 + 상태 정확도 수정

### 문제
1. OpenClaw 타임라인에 "Prompt sent" 플레이스홀더만 표시 (실제 텍스트 없음)
2. OpenClaw 활동 상태가 Claude Code와 교차 오염됨 (shared stateMachine)
3. APME 평가 파이프라인이 OpenClaw와 완전 단절
4. Gateway 연결 불안정 + 순간 끊김 시 "Not configured" 표시

### 해결

**상태 격리 (DaemonServer.swift)**
- `gatewaySessionState` / `gatewayCurrentTool` / `gatewayModelName` — gateway 전용 필드 추가
- `handleGatewayEvent()`에서 `stateMachine.transition()` 제거 → shared stateMachine은 Claude Code hooks만 담당
- `buildSessionsListEvent()`에서 gateway 세션이 gateway 전용 필드만 사용

**APME 연동**
- `apmeCollectorGateway` — Claude Code `apmeCollector`와 분리된 전용 인스턴스 (activeHookSession 충돌 방지)
- Gateway 연결 → `session_start`, 끊김 → `session_end`
- `session.message` role=user → `user_prompt_submit`, `chat` final → `setTurnResponse()` → 분류+평가 자동 트리거
- `session.tool` pending/complete → `tool_start`/`tool_end`

**타임라인 텍스트 캡처**
- `chat` delta `prompt` 필드 → `model_call` timeline 엔트리 (실제 프롬프트 텍스트)
- `chat` final `response` 필드 → `model_response` timeline 엔트리 (전체 응답)
- `sessions.messages.subscribe` 의존 제거 — `chat` 이벤트에서 직접 캡처
- `DaemonTimelineEntry.runId` 추가 — turn-level 그루핑 가능

**"Prompt sent" 억제 (AgentStateHolder.swift)**
- `gatewayConnected == true` 수신 시 즉시 `receivingBridgeTimeline = true` 설정
- StateTimelineGenerator 폴백이 gateway 연결 중 실행되지 않음

**Gateway 연결 안정성**
- `gatewayProbeFailCount` — 2회 연속 실패(≈10초) 후에만 disconnect 트리거
- "reconnecting" 상태 추가 — WebSocket 끊김 + TCP 살아있을 때 "Not configured" 대신 표시
- APME gateway 스펙 감사: `exec.approval.resolved` deny 처리, `session.tool` → gatewayCurrentTool, disconnect 시 state 리셋

### 핵심 설계 결정

**1. apmeCollectorGateway 분리**  
`ApmeCollector`는 `activeHookSession` 단일 상태 → Claude Code와 OpenClaw를 같은 collector에 넣으면 turn routing 충돌. 동일 `ApmeStore` + `ApmeRunner`를 공유하되 collector만 분리하여 평가 파이프라인은 하나로 유지.

**2. chat 이벤트가 타임라인의 신뢰할 수 있는 단일 소스**  
`sessions.messages.subscribe`는 연결 시 활성 세션이 없으면 subscription이 silent fail할 수 있음. `chat` delta `prompt` + `chat` final `response`는 구독 없이 항상 수신. `session.message`가 나중에 도착해도 5초 dedup으로 자동 처리.

**3. gateway probe hysteresis threshold = 2**  
단일 TCP 실패(5초 probe)로 즉시 disconnect하면 일시적 지연에도 "Not configured" 노출. 2회(≈10초) 연속 실패를 요구하면 adapter 내부 backoff reconnect가 처리하고 UI는 변화 없음.

**4. receivingBridgeTimeline 선제 설정**  
StateTimelineGenerator가 `timeline_event` 도착을 기다리다 state_update에 먼저 반응하는 레이스 제거. Gateway connected 상태가 확인되면 즉시 generator를 억제.

---

## 2026-04-28 — Stream Deck / D200H 세션 UX 아이콘·배치 수정

### 문제
- D200H 세션 타일이 terrarium creature 가 아니라 provider logo path 를 그려 Codex가 깨진 clover/logo처럼 보임.
- D200H processing dashed/stitch 테두리가 정지 이미지 파이프라인에서 어색하고, 왼쪽 상태선과 작업 테두리가 겹침.
- Stream Deck+ 8키 기준의 detail 레이아웃이 Stream Deck 15키에서 그대로 해석되어 ESC/Back 위치와 pagination 이 물리 버튼 수를 반영하지 못함.

### 해결
- 공용 SVG 세션 렌더러: Claude robot, Codex cloud prompt, OpenClaw crayfish, OpenCode nested square mini creature 로 교체. Processing 상태는 dashed border 대신 solid/pulse ring + RUN badge 로 변경.
- D200H Swift 렌더러: `rendererRev=creature-session-icons-v22`, logo path 렌더링 제거, Codex cloud/Claude robot/OpenCode square를 CoreGraphics로 직접 그림. 상태선은 테두리 안쪽으로 이동해 겹침 제거.
- 플러그인 슬롯 매니저: `DeckLayout(columns, rows, keyCount)` 기반으로 list/detail 슬롯을 계산. SD+는 4×2, Stream Deck은 5×3 프로필을 사용하며 ESC/STOP은 항상 마지막 물리 키에 배치.
- Stream Deck classic용 bundled profile `agentdeck-sd` 추가, SD+는 기존 `agentdeck-sdplus` 유지.

---

