# AgentDeck Development Log — 2026-05

Archived from the active [DEVELOPMENT_LOG.md](../../DEVELOPMENT_LOG.md). Entries are ordered newest date first; search this file directly when investigating May 2026 work.

---

## 2026-05-24 — Codex App / Codex CLI 세션 표시 분리

### 문제
- Codex App OTel만 들어온 세션이 `codex:otel-active` + `agentType=codex-cli` + 빈 `projectName`으로 생성되어 D200H/타임라인에서 `OPENCLAW_CODEX-CLI__`처럼 빈 프로젝트명으로 보였다.
- Codex CLI lifecycle hook 세션과 Codex App OTel 세션을 모두 `codex-cli`로 취급해 같은 프로젝트에서 두 종류가 동시에 살아 있어도 display folding이 하나로 합쳐질 수 있었다.

### 해결
- OTel 기반 Codex App 세션은 새 `agentType=codex-app`으로 생성하고, cwd가 아직 없을 때는 프로젝트명을 `Codex App`으로 채운다. 이후 cwd가 들어오면 실제 프로젝트명으로 승격한다.
- Codex CLI와 Codex App folding key를 `(agentType, projectName)`으로 맞춰 같은 `AgentDeck` 프로젝트라도 두 세션 종류가 별도 타일/creature로 보이게 했다.
- macOS, Android, Stream Deck/D200H, Pixoo, bridge/plugin/shared 렌더링 경로에 `codex-app` label/icon/color/rank를 추가했다.
- 검증: `xcodebuild test -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS' -only-testing:AgentDeckTests_macOS/ProtocolTests -only-testing:AgentDeckTests_macOS/TerrariumCloudFoldTests`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest`, `pnpm vitest run shared/src/__tests__/session-utils.test.ts`, `pnpm --filter @agentdeck/shared typecheck`, `pnpm --filter @agentdeck/shared build`, `pnpm --filter @agentdeck/plugin typecheck`, `pnpm --filter @agentdeck/bridge typecheck` 통과.

---

## 2026-05-24 — ESP32 / Codex App 장치 표시 정합성

### 문제
- 일부 ESP32 경로가 `codex-app`을 모르는 상태라 Codex App 세션을 Codex cloud가 아니라 Claude/octopus fallback으로 분류할 수 있었다.
- `sessions_list`에서 `agentType`이 비어 있거나 알 수 없는 값이면 Claude로 기본 처리해 실제 canonical session 목록에 없는 Claude creature가 표시될 수 있었다.
- 임시 `esp32/scripts/serial_relay.py`는 `/health`에 `agentType`이 없을 때 `claude-code`를 주입하고 canonical `/sessions`를 전달하지 않아, CLI 실행 전후 장치별 표시가 달라지는 경로를 만들었다.
- macOS Setup card는 기존 Codex auth 신호가 보일 때만 Codex observation 설정을 노출해, Codex CLI를 먼저 실행해야 Codex App 관측 설정 진입점이 보이는 것처럼 보일 수 있었다.

### 해결
- ESP32 protocol / terrarium / TC001 matrix / HUD 분류를 `codex-cli`와 `codex-app` 모두 Codex cloud로 처리하도록 확장했다.
- ESP32의 unknown/missing `agentType`을 Claude로 승격하지 않게 바꿔 phantom Claude creature를 차단했다.
- 임시 serial relay는 기본 `agentType`을 `daemon`으로 바꾸고 `/sessions` 또는 `/status.sessions`를 `sessions_list`로 전달하도록 수정했다.
- macOS Setup card는 Codex auth 신호 유무와 무관하게 `codexConfigInstalled == false`이고 사용자가 거절하지 않았으면 Codex live observation 설정을 노출한다.

---

## 2026-05-24 — Codex App passive detection / App Store subscription cleanup

### 문제
- Codex Desktop(App) kernel process가 이미 떠 있어도 OTel turn이 들어오기 전에는 Swift App Store daemon의 session list에 Codex App 타일이 늦게 나타날 수 있었다.
- OTel 익명 thread(`codex:otel-active`)가 별도 세션으로 남아 Codex App observed session과 중복 표시될 수 있었다.
- Claude Code만 미설정인 상황에서도 다른 agent 세션이 보이면 setup alert가 불필요하게 Claude Code를 요구했다.
- App Store daemon이 ChatGPT/Codex auth metadata를 subscription row로 합성해 Android tablet/e-ink surfaces에 renewal-needed row를 노출할 수 있었다.
- `/health`와 `/status`가 serial actor 상태 조회를 직접 기다려 물리 ESP32 쓰기 중 포트 탐색이 느려질 수 있었다.

### 해결
- macOS에서 subprocess 없이 `sysctl(KERN_PROC_ALL/KERN_PROCARGS2)`와 `NSRunningApplication`으로 Codex App kernel process를 passive 관측하고 `observed:codex-app:<sessionId>` 세션을 만든다.
- OTel `otel-active` 이벤트는 관측된 Codex App 세션으로 라우팅하고 기존 anonymous pushed state를 purge한다.
- Setup card는 보이는 agent가 하나라도 있고 그 agent가 Claude Code가 아니면 Claude Code setup 항목/alert를 숨긴다.
- App Store Swift daemon의 `subscriptions` payload는 빈 배열도 항상 전송하며, ChatGPT/Codex subscription row 합성은 제거했다.
- `/health`/`/status` module health는 serial actor await 대신 out-of-band poll cache를 사용해 hook port discovery 응답성을 유지한다.
- ESP32 serial bridge에서 write backpressure가 연속 발생하면 연결을 닫고 transient reconnect 경로로 넘긴다. Round AMOLED가 `write stalled after 0 ... errno=35` 상태로 계속 열려 있으면 `device_info`/`sessions_list`를 못 받아 loading 화면에 머물 수 있었다.

## 2026-05-24 — Round AMOLED legacy Codex App 호환

### 문제
- Round AMOLED 보드는 자동 업로드가 ROM bootloader 진입에 실패해 Codex App 지원 펌웨어를 아직 플래시하지 못했다.
- 해당 보드는 `version=0.1.0`으로 붙고, 구형 펌웨어는 `codex-app` agent type을 모르면 Claude Code creature fallback으로 표시할 수 있었다.

### 해결
- Swift serial bridge가 `round_amoled` + firmware `< 0.1.1` + protocol revision 미광고 보드에만 `codex-app`을 `codex-cli`로 내려보낸다.
- 변환은 per-connection으로 적용해 Android/D200H/Stream Deck 및 새 ESP32 펌웨어의 Codex CLI/App 분리는 유지한다.
- `device_info`를 받은 직후 initial state를 다시 보내, 재연결 시 `sessions_list`가 구형 round 보드에도 보정된 agent type으로 도착하게 했다.
- 새 ESP32 펌웨어는 `version=0.1.1`, `protocolRevision=2`를 광고한다. 이 버전이 round에 플래시되면 호스트 별칭 없이 `codex-app`을 직접 렌더링한다.

### 검증
- `xcodebuild test -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS' -only-testing:AgentDeckTests_macOS/ProtocolTests`
- `pio run -e round_amoled`
- 실제 round 업로드는 여전히 `Failed to connect to ESP32-S3: No serial data received`로 실패했다. 물리 BOOT/RST로 ROM bootloader에 넣어야 플래시 가능하다.

## 2026-05-17 — macOS MenuBar 팝업 가변 사이징 + ScrollView greedy 해결

### 문제

macOS 메뉴바 드롭다운 (`ControlTowerPanel`) 팝업이 컨텐츠보다 작게 떠서 항상 세로 스크롤바가 노출됨. 사용자 요청: 컨텐츠에 따라 가변적으로 늘어나 스크롤이 최대한 안 생기게.

세부 원인 3 가지:
1. **고정된 chrome 예약 140 pt**: `scrollContentMaxHeight = max(360, screenHeight * 0.85 - 140)` 가 실제 header/banner/footer 높이와 무관. CalmHeader 일 때 chrome 이 ~140 이라 작은 차이지만, AttentionTheater + DaemonOfflineBanner 동반 시 실측 ~290 이 되어 cap 부족.
2. **너비 380 pt 고정**: 긴 projectName / modelName 이 truncate 되며 세로 줄바꿈으로 vertical 부담 가중.
3. **SwiftUI ScrollView greedy 함정** (핵심): `ScrollView { content.fixedSize(vertical: true) }.frame(maxHeight: X)` 패턴에서 `fixedSize` 는 **content** 의 intrinsic 만 고정할 뿐 ScrollView 자체는 여전히 부모가 제안한 `maxHeight` 를 무조건 가득 채움. content 가 frame 보다 작아도 ScrollView 가 frame 만큼 자라 빈 공간이 생기거나, content 가 조금이라도 크면 즉시 스크롤바 발생.

### 해결

**`apple/AgentDeck/UI/MenuBar/ControlTowerPanel.swift`**
- `ChromeHeightKey` PreferenceKey + `measureChromeHeight()` View extension 추가 — header Group / banner+pillActionsBar / footerSection 3 위치의 실측 높이를 reduce-합산.
- `ContentHeightKey` PreferenceKey 추가 — body VStack 에 GeometryReader backing 으로 natural 높이 publish (single-source 라 reduce 는 `value = nextValue()` 로 latest 채택).
- ScrollView frame 을 `min(scrollContentMaxHeight, measuredContentHeight)` 로 명시 바인딩 — ScrollView greedy 우회. content < cap 이면 ScrollView 가 content 크기로 줄어들고 scrollbar 안 뜸.
- `showsIndicators: measuredContentHeight > scrollContentMaxHeight` 조건부 — 진짜 overflow 일 때만 표시.
- `scrollContentMaxHeight` = `max(80, screenHeight - max(140, measuredChromeHeight) - 24)`. `screenHeight * 0.85` 계수 제거 (`visibleFrame` 이 이미 menubar+Dock 제외). 80 pt 플로어는 1 세션 행 가시성 보장; AttentionTheater 가 클 때 body 가 자연 축소되어 popup_total ≤ screen 유지.
- 팝업 너비 `.frame(width: 380)` → `.frame(minWidth: 380, idealWidth: 420, maxWidth: 460)` 가변화.
- Swift 6 strict-concurrency: PreferenceKey `defaultValue` 는 `static let`.

**`apple/AgentDeck/UI/MenuBar/AttentionTheaterView.swift`**
- 옵션 리스트 ScrollView 캡 `220 pt` 고정 → `min(380, screenHeight * 0.35)` 적응형. 일반적인 3–8 옵션 prompt 는 ceiling 에 안 닿아 scrollbar 자체 안 뜸. `/openclaw scope` 같은 30+ 옵션만 카드 내부 스크롤로 흡수.
- Sizing invariant: `options(≤380) + 140(badge/question chrome) + 150(banner+pill+footer) + 80(body floor) + 24(safety) ≤ screen`. 일반 화면 (≥800 pt) 에서 popup 절대 overflow 안 함.

### 핵심 설계 결정

- **SwiftUI ScrollView 는 부모가 제안한 height 를 항상 가득 채우는 greedy view**. `.fixedSize(vertical: true)` 를 안쪽 VStack 에 붙여도 ScrollView 자체는 안 줄어듬. ScrollView 가 content 크기로 줄어들게 하려면 별도 PreferenceKey 로 content 높이를 측정 → `.frame(maxHeight: min(cap, contentHeight))` 로 명시 바인딩 필요. macOS SwiftUI 의 잘 안 알려진 함정.
- **`max(140, measuredChromeHeight)` 형태의 fallback** 은 PreferenceKey 가 첫 frame 에 미발화 (한 runloop tick 뒤 도착) 인 경우를 위한 안전망. 일반적으로 실측치 ≥ 140 이라 `max()` 가 fallback 보다 큰 측정치를 채택해 동작에 영향 없음.
- **여러 sizing cap 은 같은 화면 예산을 나눠 쓴다는 invariant 위에 설계해야**. AttentionTheater 옵션 cap (옵션이 헤더에 포함됨) + body floor 가 독립적으로 자기 몫 주장하면 popup_total > screen 으로 overflow. cap 들의 합 + chrome 추정치 ≤ screen 을 산식으로 명시하고 각 cap 을 그 산식 안에서 정함.
- **ContentHeightKey 의 reduce 는 `value = nextValue()` (덮어쓰기), ChromeHeightKey 는 `value += nextValue()` (누적)**. 전자는 단일 GeometryReader 소스, 후자는 3 위치 합산. PreferenceKey reduce 시맨틱은 source 수에 따라 다르게 선택.
- **같은 popup 안 두 ScrollView 는 둘 다 ContentHeight PreferenceKey 패턴 필요**. 1차 fix 는 ControlTowerPanel body 만 적용했고 AttentionTheater 옵션 ScrollView 는 cap 만 조절해 greedy 그대로 남겨 — Codex stop-time review 가 잡음. 2차 fix 에서 `OptionsHeightKey` 를 file-local 로 정의 (`ContentHeightKey` 와 분리해야 같은 root 의 onPreferenceChange bubble 에서 안 섞임) + 옵션 VStack 에 GeometryReader backing + `frame(maxHeight: min(optionsCap, measuredOptionsHeight))` 명시 바인딩. **교훈**: ScrollView 인스턴스 수만큼 별도 PreferenceKey + 별도 @State 필요.

---

## 2026-05-17 — Apple Daemon: Timeline turn merge + chat_start ts FIFO queue + APME outcome 영구화 + OpenClaw 도구 가시성

### 문제

세 가지 문제가 한 세션에서 누적 발견됐다.

1. **Timeline UX**: 한 user prompt → 응답 → 완료 사이클이 3 row 로 쪼개져 보이고, task_start 회전 spinner 가 영원히 돌며, TASK 헤더가 모든 첫 메시지마다 등장. 회전 아이콘은 사각형(`list.bullet.rectangle.fill`)이라 회전이 어색.
2. **OpenClaw 도구 가시성**: `apme.sqlite` 의 OpenClaw `tool_name` 컬럼이 `"tool"` placeholder, payload 도 비어서 도구 호출이 무엇이었는지 추적 불가. APME run 들이 일괄 `task_category='_empty'` 로 분류돼 평가 무의미.
3. **APME outcome 영구화 silent drop**: `ApmeOutcomeEngine.evaluateOutcome` 의 결과 4 fields (outcome / outcomeConfidence / efficiencyJson / compositeScore) 가 DB 에 영구화되지 않아 같은 6 run ID 가 30 초마다 217 회 재로깅. 전체 DB 에 outcome 컬럼이 채워진 run 이 0 건.

### 해결

**Timeline UX (Apple)**
- `Model/Timeline.swift::groupConsecutive` 에 chat-turn merge 분기 추가 — 같은 sessionId 의 chat_start (meaningful) → chat_response → chat_end 를 `GroupedEntry.mergedResponse` / `mergedCompletion` 로 흡수해 1 row 로 표시. wall-clock window 안 보고 `child.startedAt == start.ts` anchor 매칭으로 cross-turn attach 차단.
- `Daemon/Apme/ApmeCollector.swift`: `openTaskIfNone` 의 timeline emit 을 deferred. TodoWrite 호출 또는 2nd turn 시 `emitDeferredTaskStartIfNeeded()` 가 발화. 단일-turn 짧은 대화는 TASK 헤더 안 보임.
- `ApmeCollector.idleGapSec` (default 90 s) + 4-단 race guard (`attributedToActiveTurn` / `idleGapMinTurnAgeSec` / snapshot turnId / chatEndTs) — `setTurnResponse` 끝에서만 arm.
- `UI/Monitor/TimelineStripView.swift::RotatingTimelineIcon` 에 `rotatingSymbolName` 옵션 — TASK 헤더는 정적 시 `list.bullet.rectangle.fill`, 회전 시 `arrow.triangle.2.circlepath`.

**OpenClaw 도구 가시성**
- `Daemon/Gateway/OpenClawAdapter.swift`: `emitTimelineEntry(fromSessionTool:)` 가 toolName / toolInput / toolOutput 을 out-of-band extras dict 로 노출. `firstJSONValue` 헬퍼가 NSNull (explicit JSON null) 을 absent 로 unwrap.
- `Daemon/Server/DaemonServer.swift::gatewayToolHookFromEntry` 정적 helper — entry dict 에서 구조화 필드 추출 + Claude Code shaped hook payload (`tool_name` / `tool_input` / `tool_response`) 로 매핑. 라우팅은 `status ∈ {complete, error, failed}` 또는 `toolOutput != nil` → tool_end, 그 외 → tool_start. `entry["detail"]` 은 더 이상 routing 결정에 안 쓰임 (legacy fallback 에서 detail blob 으로 running 이 tool_end 로 잘못 분기되던 버그).

**APME outcome 영구화 (Apple)**
- `Daemon/Apme/ApmeStore.swift::updateRun` 의 `colMap` 에 outcome / outcomeConfidence / efficiencyJson / compositeScore 4 entries 추가. 매핑 없는 키는 `guard let col = colMap[key] else { continue }` 에서 silent skip → SET clauses 0 개 → 조기 return → SQL UPDATE 자체 실행 X. 스키마 / readRun / migrations 는 모두 outcome 알고 있는데 updateRun 하나만 빠진 silent partial wiring. 헤더 주석으로 future-proofing 명시.

**chat_start ts FIFO queue (Apple)**
- `Daemon/Server/DaemonServer.swift`: `claudeChatStartTsBySession: [String: Double]` / `codexChatStartTsBySession` 단일-슬롯 dict → file-scope `ChatStartTsQueue` 구조체 인스턴스 2 개. chat_start = enqueue, Stop hook = dequeue head (FIFO). mid-turn 행위 (tool_exec 등) 는 `peekTail` (가장 최근 = active turn). `appendCodexChatStart` 의 upsert 분기 제거 — follow-up prompt 의 head row overwrite 차단.

### 핵심 설계 결정

- **single-slot per-session state 는 multi-turn race 에 취약**. `[String: T]` slot 이 "현재 active turn 의 T" 라는 가정은 사용자의 빠른 follow-up + 비동기 Stop hook callback 조합에서 깨진다. 모든 in-flight turn state 는 sessionId 안에서 FIFO queue (또는 turn-id keyed dict) 로 추적해야. `ChatStartTsQueue.peek` (head, "다음 Stop 이 dequeue 할 것") vs `peekTail` (newest, "지금 generating 중인 turn") 시맨틱 분리.
- **chat_response / chat_end 의 `startedAt` 는 source-of-truth anchor**. Daemon 이 originating chat_start 의 ts 를 stamp 하고, Timeline `groupConsecutive` 의 `sameTurnAnchor(start:, child:)` 가 그 anchor 와 head 의 ts 가 일치할 때만 흡수. wall-clock window 는 dedup 그룹화에만 (60 s), turn merge 에는 무관 — 긴 응답 (xcodebuild + 멀티 fix 20 분+) 을 한 turn 으로 묶기 위해.
- **Tool routing 은 status / output 기반 strict**. `entry["detail"]` 처럼 시각용 blob 의 nil 여부로 start vs end 를 결정하면 input-only running 이 end 로 잘못 분기. routing 전용 helper (`gatewayToolHookFromEntry`) 를 testable 한 static 으로 분리.
- **colMap 같은 single-source mapping table 은 silent-failure 위험**. 키 누락이 컴파일 에러 없이 SQL UPDATE 자체를 무력화. 헤더에 "extending 시 mirror 위치" 명시 + 회귀 가드 (round-trip 테스트) 필수.
- **NSNull defense in depth**: JSON-decoded `[String: Any]` 의 explicit `null` 은 `Optional.some(NSNull())`. `!= nil` 통과. producer / router 양쪽에서 unwrap.
- **spinner stop 은 chat_end 가 아닌 "응답 도착" 신호 기반**. chat_end 는 비신뢰성 path (Stop hook ~18% reliability + async summarize). chat_response 는 sync broadcast 라 더 신뢰성 있음. `GroupedEntry.hasResponse` = `mergedResponse != nil || mergedCompletion != nil` 둘 중 하나라도 도착하면 turn "delivered". chat_end 누락에도 spinner 정상 종료.

회귀 가드 33+ 종 신규 (TimelineTests + ApmeTaskBoundaryTests). Codex stop-time review 11 차에 걸쳐 누적 발견된 race / mis-routing / hang-induced-spinner 시나리오를 케이스별로 명시 회귀 가드 + 메모리 노트 (timeline-turn-merge-lazy-task / openclaw-tool-payload-mapping / apme-updaterun-colmap-trap).

---

## 2026-05-11 — Android E-ink Dashboard 3-section redesign

### 문제

`E-ink Dashboard.html` 디자인 핸드오프의 최종 방향은 App Store 출시 준비 맥락에서
E-ink 화면을 세션 목록 / 테라리움 / 텍스트 Timeline 중심으로 단순화하는 것이었다.
기존 Android `EinkMonitorScreen` 은 중간 status band 에 LIMITS / MODELS / DEVICES 를
크게 표시하고, landscape 에서 좌측 session rail 이 전체 높이를 차지해 실제 디자인의
"상단 Sessions|Terrarium + 하단 Timeline" 구조와 어긋났다.

### 해결

- Android E-ink landscape 를 chrome bar + optional Attention strip + `Sessions | Terrarium`
  상단 row + full-width text `EinkTimelinePanel` 하단 row 로 재구성했다.
- Portrait 도 동일 의미의 `Sessions / Terrarium / Timeline` 3단 stack 으로 정리하고,
  terrarium 을 세로 lane 으로 늘리지 않게 유지했다.
- rotate/settings controls 를 screen chrome 으로 이동했다. rotate 는 settings button
  표시 여부와 독립적으로 계속 노출된다.
- LIMITS 는 fresh 5h/7d usage 값이 있을 때만 terrarium 우하단 작은 corner card 로
  표시한다. 값이 없거나 stale 이면 App Store-safe progressive enhancement 원칙대로
  섹션 자체를 숨긴다.
- 기존 `EinkAgentPanel` 은 brand header / footer controls 를 숨길 수 있는 옵션을
  받아 chrome 이 별도로 존재하는 dashboard layout 에 재사용되도록 했다.
- `docs/android-ui.md` 의 E-ink projection 설명을 새 3-section layout 으로 갱신했다.

### 검증

- `cd android && ./gradlew testDebugUnitTest` — BUILD SUCCESSFUL.

---

## 2026-05-11 — Codex lifecycle hook feature flag rename

### 문제

Codex CLI 가 `⚠ [features].codex_hooks is deprecated. Use [features].hooks instead.`
경고를 출력했다. 현재 사용자 `~/.codex/config.toml` 의 AgentDeck fenced block 과
AgentDeck 의 Codex observation installer(Node + Swift)가 모두 예전
`[features] codex_hooks = true` 키를 쓰고 있어 수동 수정 후에도 재설치 시 경고가
되살아날 수 있었다.

### 해결

- 사용자 `~/.codex/config.toml` 의 AgentDeck fenced block 을 `[features] hooks = true`
  로 즉시 마이그레이션했다.
- Node `hooks/src/codex-install.ts` 와 macOS `CodexConfigInstaller.swift` 가 새
  `hooks = true` feature flag 를 쓰도록 변경했다.
- Node/Swift 테스트 기대값과 App Review notes 의 Codex observation 설명을 새 키로
  갱신했다.
- OpenAI docs MCP 의 config reference 는 아직 `features.codex_hooks` 로 표시되어
  있었지만, 로컬 Codex CLI 의 deprecation warning 을 현재 런타임 기준으로 채택했다.

---

## 2026-05-11 — macOS App Store export certificate selector cleanup

### 문제

`scripts/build-apple-release.sh --macos` 에서 archive 와
`apple/scripts/verify-appstore-archive.sh` 는 통과했지만, export 단계가
`No certificate for team 'R22679GY5Z' matching 'Mac Installer Distribution' found`
로 실패했다. 로컬 키체인에는
`3rd Party Mac Developer Installer: SEUNG BEOM CHOI (R22679GY5Z)` identity 가
존재했으므로 인증서 부재가 아니라 Xcode 26.4.1 의 automatic selector 해석 문제였다.

### 해결

- `apple/ExportOptions-macOS.plist` 의 `installerSigningCertificate` 를
  `Mac Installer Distribution` 자동 선택자에서 실제 설치된 installer identity common
  name 으로 고정했다.
- `docs/asc-cert-setup.md` 에도 동일한 값과 문제 원인을 반영했다.

### 검증

- `plutil -lint apple/ExportOptions-macOS.plist` — OK.
- `env -u ASC_API_KEY_ID -u ASC_ISSUER_ID bash scripts/build-apple-release.sh --macos`
  — archive succeeded, App Store archive verifier passed, `dist/export_macos/AgentDeck.pkg`
  export succeeded. TestFlight upload 은 의도적으로 env 를 비워 skip.

---

## 2026-05-10 — App Store release readiness: APME dashboard + Device Preview catalog

### 문제

App Store 제출 전 점검에서 Evaluation(APME) 과 Device Preview 표면에 실제 동작/문서
불일치가 있었다.

- APME dashboard 는 `/apme?token=...` 로 열리지만 후속 `fetch('/apme/runs')`,
  `fetch('/apme/run/...')`, `POST /apme/vibe` 에 token 을 붙이지 않았다. Node
  daemon 의 APME 라우트는 token-gated 라서 평가 UI 가 401 로 비어 보일 수 있었다.
- Swift APME HTTP detail payload 가 `vibe` 와 `turnEvals` 를 싣지 않아 App Store
  in-process daemon 에서는 vibe column / turn-level judge 점수가 dashboard 에 반영되지
  않았다.
- Swift eval result timeline append 는 저장만 하고 `timeline_event` broadcast 를 하지
  않아 live dashboard/device timeline 에 ★ eval_result 행이 즉시 뜨지 않았다.
- Device Preview 문서/주석은 "14 targets" 라고 쓰여 있었지만 실제 catalog 는 16개,
  App Store standalone visible set 은 ADB-tier 4개를 숨긴 12개였다.

### 해결

- APME dashboard HTML(TS source + App Store bundled resource)에 `api(path)` helper 를
  추가해 현재 URL 의 `token` 을 모든 APME fetch/POST 에 전파.
- Swift `ApmeHttpRoutes` 를 Node shape 에 맞춰 보강: schema envelope, `/apme/run/<id>`,
  `vibe`, per-turn `turnEvals`, authenticated UI routes, GET/POST recommend parity.
  FoundationModels judge relay endpoint 은 Node runner 호환을 위해 기존처럼 same-machine
  POST 로 유지.
- `appendEvalResultTimeline` 이 `timeline_event` 를 즉시 broadcast 하도록 수정하고,
  `layer1SkippedReason` 을 WS/HTTP payload 에 실어 App Store LLM-only 평가 상태가
  dashboard 에 표시되게 함.
- APME empty-state copy 에서 "Launch a session from the menubar" 문구를 제거하고,
  사용자가 자기 workspace 에서 agent 를 실행한다는 App Store-safe copy 로 정리.
- Device Preview catalog count 를 16 total / 12 standalone / 4 desktop-bridge 로 문서화하고
  `APP_REVIEW_NOTES.md`, `docs/appstore-feature-matrix.md` 를 실제 UI 정책에 맞춤.
- APME runner 테스트가 실제 사용자 App Store container 의 `daemon.json` 상태에 의존하지
  않도록 FoundationModels fallback 테스트에 임시 `AGENTDECK_DATA_DIR` 를 주입.
- Vitest 전역 setup 에서 worker 별 임시 `AGENTDECK_DATA_DIR` 를 설정해 전체 test suite 가
  로컬 설치/실행 중인 AgentDeck daemon 상태에 의존하거나 hang 하지 않도록 격리.
- Stream Deck plugin `ConnectionManager` 도 `AGENTDECK_DATA_DIR` override 를 존중하게 해
  테스트/개발 환경에서 App Store container fallback 파일을 직접 읽지 않도록 수정.

---

## 2026-05-10 — Timeline rotation + Android entry-filter parity (in-flight task hierarchy)

### 문제

사용자가 Android tablet timeline 과 macOS/iOS timeline 사이에 두 가지 차이를
보고했다.

1. "android tablet 은 진행중 태스크 상태가 회전 이모지로 안내되는데
   macOS/iOS 에는 적용이 안 된 것 같다."
2. 두 클라이언트가 보여주는 timeline 로그가 "표시되는 entry 종류/개수"
   측면에서 다르다.

코드 조사 결과 — 회전 자체는 양쪽 모두 `chat_start` + unknown type 에 대해
이미 평행하게 동작했지만 (`shared/src/timeline-icons.ts` 단일 spec, Apple
`RotatingTimelineIcon` ↔ Android `rememberRunningRotation` 1.8s 회전), 두 가지
실제 갭이 있었다.

- **회전 의미 부족**: 양쪽 모두 task hierarchy (`task_start` ↔ `task_end`)
  마커는 정적 `.task` 아이콘으로만 그려졌다. `task_start` 가 완료되지
  않았는데도 회전 신호가 없어서 "지금 진행 중" 표시가 hierarchy 레이어에
  부재했다.
- **Entry 필터 비대칭**: Apple `timelineDisplayGroupsForDashboard` 는
  `timelineIsLowSignalEntry` (codex:otel-active 의 `tool`/`exec` 노이즈)와
  meaningful chat_start 보존 룰을 모두 가지고 있었지만 Android
  `timelineDisplayGroups` 에는 둘 다 없었다. 결과로 Android 가 Apple 보다
  훨씬 많은 노이즈 row 를 보여주어 "회전이 더 풍부하다"는 인지를 만들고,
  반대로 Apple 은 적은 row 사이에서 회전이 잘 안 보인다는 인지를 만들었다.

### 해결

1. **Shared 술어 신규** (`shared/src/timeline-icons.ts`):
   - `isInFlightTask(entry, siblings)` — `task_start` 인데 같은 `taskId` 의
     `task_end` 가 siblings 에 없으면 true.
   - `isRotatingEntry(entry, siblings)` — `iconKey === 'running'`
     (chat_start, unknown) ∪ in-flight task. iconKey 자체는 단일-entry
     함수로 유지해 테스트 단순.
2. **Apple 포팅** (`TimelineStripView.swift`):
   - `timelineIsInFlightTask` / `timelineIsRotatingEntry` Swift 미러 추가.
   - 회전 호출지점 3곳 모두 새 술어로 전환: `turnRow` (라인 293),
     `taskHeaderRow` (라인 449 — 정적 `Image` 를 `RotatingTimelineIcon` 으로
     교체), `detailPane` (라인 531).
   - **taskHeaderRow 는 turnRow 와 분리된 별도 경로**라서 turnRow 만 패치하면
     `task_start` row 가 빠진다 — 양쪽 다 수정해야 task hierarchy 회전이
     실제로 보인다.
3. **Android 포팅** (`TimelineIcons.kt` + `TimelineStrip.kt`):
   - Kotlin `isInFlightTask` / `isRotatingEntry` 추가.
   - `CompactLogRow` → `TurnRow` / `TaskHeaderRow` 에 `siblings: List<TimelineEntry>`
     파라미터 추가, `displayEntries` 를 `TimelineList` 에서 흘려보냄.
   - `TaskHeaderRow` 의 정적 Icon 을 `rotate(taskAngle)` modifier 로 감싸서
     in-flight 회전 적용.
4. **Apple 필터 Android 백포트** (`state/TimelineDisplay.kt`):
   - `isLowSignalEntry`: codex-cli + sessionId == "codex:otel-active" + type
     ∈ {tool_exec, tool_request, tool_resolved} + raw ∈ noise set 6종 →
     drop.
   - `isMeaningfulChatStart`: synthetic 시작 row 5종 (`prompt sent`,
     `codex turn started`, `starting chat`, `connected`, `resumed`) 만 completion
     도착 시 elide. 의미 있는 사용자 프롬프트는 응답 옆에 유지 (Apple과 동일).
   - `chat_end` 도 Apple과 동일하게 `summaryKind` 값에 따라 분기:
     `llm`/`heuristic` 이면 `chat_response` 와 별개로 살아남고, `none`/`null`
     이면 기존 dedup-with-chat_response 룰 적용.

### 핵심 설계 결정

- **회전 트리거는 iconKey 와 분리**된 별도 술어로 모델링한다. iconKey 는
  entry 단독 함수, isRotatingEntry 는 sibling 의존 술어. iconKey 의 단순성
  (테스트 가능, 캐시 가능) 을 유지하면서 hierarchy 의 시간적 의미를 따로
  집어넣는다.
- **siblings 는 visible group 이 아닌 실제 timeline entries 를 넘긴다**.
  visible group 은 dedup 로 task_end 가 잠시 사라질 수 있는 반면, full
  timeline 은 안정적이다. Android 는 `displayEntries`, Apple 은
  `grouped.map(\.entry)` 를 사용 (Apple 의 grouping 은 task hierarchy 를
  group 하지 않으므로 동등).
- **Android 가 Apple 의 baseline 으로 수렴**한다 (사용자가 명시 선택). 반대
  방향 — Apple 필터를 완화 — 도 가능했지만 codex:otel 노이즈는 객관적으로
  사용자에게 가치가 없으므로 Apple 의 결정이 옳다.

### 검증

- `pnpm test` — 53 files / 1212 tests passed (vitest, +14 신규 술어 케이스).
- Android `./gradlew testDebugUnitTest` BUILD SUCCESSFUL — `TimelineTaskHierarchyTest`
  +10 신규 (isInFlightTask / isRotatingEntry), `TimelineDisplayScenarioTest`
  +3 신규 (codex otel filter / synthetic chat_start / chat_end summaryKind).
  기존 `multi-agent dashboard timeline` 케이스는 새 정책에 맞춰 assertion
  보정 (linter 자동) — meaningful "Fix Android timeline" 프롬프트가 응답 옆에
  유지되도록 검증.
- Apple `xcodebuild` macOS Debug + iOS Simulator Debug — 모두 BUILD SUCCEEDED.
- 시각 검증은 미수행 (memory `feedback_verify_visual_output` 정책상 사용자 캡처
  필요). long-running task 케이스: `agentdeck claude` + `TodoWrite` 시작 →
  `task_start` 회전 → `task_end` 도착 시 정지 의 패턴을 macOS dashboard 와
  Android tablet 에서 동일하게 확인 권장.

### 추가 수정 (Codex stop-time review)

OTel 필터를 Android `state/TimelineDisplay.kt` 의 표시 경로에만 추가했더니
`TimelineStore` 에는 여전히 노이즈가 들어가 (1) `entries` flow 의 다른
컨슈머에 노출, (2) 500-entry MAX 버퍼가 OTel 노이즈로 채워져 유용한 row 가
조기 aged-out, (3) 향후 on-disk persistence 에 그대로 흘러갈 위험이
남았다. Apple 은 `DaemonTimelineStore` add/load 단에서도 같은 룰을
적용하고 있어 비대칭. 후속 커밋 `db4491f2 fix(android): apply OTel
low-signal filter at TimelineStore add path` 에서:

- `isLowSignalEntry` 를 `private` → `internal` 로 끌어올려 같은 패키지의
  `TimelineStore` 가 직접 참조하도록 함. 룰 중복 회피.
- `TimelineStore.addEntry` / `addEntries` / `upsertEntry` 가 OTel
  low-signal 후보를 short-circuit 으로 거부.
- `TimelineStoreTest` 4 cases (단일 add 거부 / 같은 sentinel 세션의
  meaningful raw 유지 / 일괄 replay 필터 / upsert add fallback 거부) 추가.

---

## 2026-05-10 — Swift daemon ESP32 false-failure / Usage API fallback stability

### 문제

운영 중인 macOS Debug daemon 로그에서 ESP32 기기 자체는 정상 연결 상태인데도
UART serial write 의 일시적 `EAGAIN`/backpressure(`errno=35`)가 hard
failure 로 분류되어 `portFailures` 와 disconnected 상태로 전파되는 현상이
확인됨. 동시에 `sessions_list` 동일 payload broadcast 가 짧은 시간에 반복되어
serial writer 를 더 압박했고, App Store Swift daemon 이 직접 읽을 수 없는
Claude Code OAuth keychain 경로를 Usage API Tier 3 에서 계속 시도해 실패 로그를
발생시켰음.

### 해결

- `ESP32Serial` write path 에 backpressure 분류를 추가. `EAGAIN`/zero write
  는 2초까지 재시도하고, 끝까지 밀리면 연결을 유지한 채 throttled debug 로만
  기록한다. 실제 hard failure 일 때만 read token invalidation 과
  `failedPorts` 기록을 수행.
- partial write 뒤 다음 payload 앞에 line reset newline 을 붙여 JSONL frame
  오염 가능성을 줄임. 성공 write/read/device_info 수신 시 해당 port 의 stale
  failure 를 제거.
- `device_info` 미수신은 즉시 reconnect 하지 않고 요청을 재시도한다. 완전히
  silent 인 port 만 120초 이후 reconnect 대상으로 분류.
- `statusSnapshot.portFailures` 는 현재 connected port 의 stale failure 를
  노출하지 않도록 필터링하고, `lastReadAt`/`lastWriteAt`/요청 횟수를 추가해
  진단성을 높임.
- `DaemonServer.broadcastSessionsList()` 에 stable JSON fingerprint 기반 dedupe
  를 추가해 동일한 `sessions_list` broadcast storm 을 억제.
- `UsageAPIClient.directOAuthUsageSupported = false` 경계를 명시하고 Swift
  daemon 에서는 직접 OAuth usage fetch 를 건너뛰게 함. Usage path 는 sibling
  relay / Admin API / stale cache semantics 로만 동작.

### 검증

- `xcodebuild -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -destination 'platform=macOS,arch=arm64' -configuration Debug build
  CODE_SIGNING_ALLOWED=NO -derivedDataPath /tmp/AgentDeckStabilityBuild`
- `xcodebuild -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -destination 'platform=macOS,arch=arm64'
  -only-testing:AgentDeckTests_macOS/UsageAPIClientThreadingTests test
  CODE_SIGNING_ALLOWED=NO -derivedDataPath /tmp/AgentDeckStabilityTests`

---

## 2026-05-10 — App Store timeline LLM 요약 (FoundationModels → MLX → Ollama → heur)

### 문제

App Store macOS 빌드의 timeline `chat_end` 행은 휴리스틱 한 줄 추출
(`extractTopicHint`)만 사용했음. 같은 위치에서 Node bridge 빌드는
MLX(`Qwen3.6-35B-A3B-4bit`)로 LLM 요약을 생성하므로 standalone App Store
앱은 dashboard / device renderer 가 "응답 첫 줄 잘라낸" 라벨만 받음.
인프라(`apple/AgentDeck/Daemon/Timeline/TimelineSummarizer.swift` MLX/Ollama
체인 + `ApmeJudgeFoundationModels.swift` Apple Intelligence 어댑터)는
이미 깔려 있었지만 호출 site 가 없는 dead code 였음.

추가 제약: App Store 심사 invariants(2.5.2 self-contained / 4.2.3 no
install nudge) 와 `feedback_cost_sensitive_defaults.md` (default 백엔드
무료) 를 모두 보존해야 함.

### 해결

- `TimelineSummarizer.summarize(_:provider:)` 시그니처 변경 — `(text, kind)?`
  반환. `SummaryProvider` enum 추가 (`auto / appleIntelligence / mlx /
  heuristic`). `auto` 체인은 FoundationModels → MLX → Ollama → 휴리스틱
  순서, 명시 픽은 short-circuit. FoundationModels 분기는
  `ApmeJudgeFoundationModels.swift` 의 `#if canImport(FoundationModels)` +
  `#available(macOS 26.0, *)` + `SystemLanguageModel.default.availability`
  패턴 그대로 미러.
- `DaemonTimelineEntry` + `claudeCodeEntryDict` 에 `summaryKind: String?`
  추가. 디스크 round-trip + WS broadcast 양쪽에서 보존되도록 plumbing.
  앞서 dict serializer 가 필드를 빠뜨려 dashboard 가 항상 nil 로 디코드한
  버그를 stop-time review 가 잡음.
- `appendClaudeCodeChatEnd` (DaemonServer.swift:4612) + `appendCodexChatEnd`
  (DaemonServer.swift:4491): chat_response 즉시 broadcast 유지, chat_end
  빌드 + add + broadcast 를 `Task { ... }` 로 감싸 LLM await 후 실행.
  캡처는 by-value 라 main-actor 의 후속 cache cleanup 이 in-flight Task 를
  영향 안 줌.
- `AppPreferences.timelineSummaryProvider` (default `"auto"`) +
  `writeTimelineSummaryProviderToSettingsJson` 으로 `~/.agentdeck/
  settings.json` `timeline.summary.provider` 키에 미러.
- Settings → Advanced 에 `.timelineSummary` 신규 섹션. APME judge backend
  picker 패턴 그대로 (FoundationModels 비가용 시 inline grayed reason,
  MLX 행 secondary text 는 APME 와 동일 카피).
- `TimelineStripView::turnRow` 의 chat_end 에 한 줄 백엔드 pill (AI / MLX
  / Ollama / Heur). 휴리스틱도 라벨링 — App Store deploy target 이 macOS
  15 인데 FoundationModels 는 macOS 26+ 라, 대다수 사용자가 항상 휴리스
  틱 분기로 떨어짐. 휴리스틱을 숨기면 picker 변경의 가시 신호가 0 이라
  Codex review 가 두 번 잡음.
- `docs/appstore-feature-matrix.md` 행 추가, `apple/APP_REVIEW_NOTES.md`
  단락 추가.

### 핵심 설계 결정

- **FoundationModels 를 chain head 에 둠** — APME 와 동일 정책. 무료/오프
  라인/sandbox 안전. macOS 15 deploy target 에서 `#available` gate 로
  graceful fallback. 별도 OS 분기 코드 없음.
- **Ollama 는 `auto` 체인의 내부 fallback, picker 노출 X** — APME 가
  Ollama 옵션을 노출 안 한 선례 따름. 사용자 picker 는 의도/기대치
  표현용; 자동 fallback 이 항상 휴리스틱으로 떨어짐을 보장하면 충분.
- **chat_end Task 감싸기 vs upsert 두 단계** — 단순함을 택함. chat_end 는
  dimmed metadata 행이라 1-5s 지연이 사용자 체감에 무해. chat_response
  의 본문 broadcast 는 즉시 유지하므로 응답 지연감 없음.
- **summaryKind 값은 `"appleIntelligence" | "mlx" | "ollama" |
  "heuristic"` 4종, `"none"` 미사용** — 기존 UI 가 `"none"` 을 "detail
  표시 억제" 시그널로 쓰지만 새 경로는 항상 raw 에 의미있는 라벨이 들어
  가서 억제할 게 없음. detail-redundancy 검사가 그 자리를 대체.
- **App Store 심사 invariant**: subprocess 0건, install-nudge 카피 0건,
  Anthropic API 등 paid backend 는 picker 미포함. `verify-appstore-archive
  .sh` main Mach-O 스캔 통과.
- **stop-time review 두 라운드 회고**:
  (1) `claudeCodeEntryDict` 에서 `summaryKind` 빠뜨린 silent serialization
  bug 는 unit test 도, build 도 잡지 못함. dict-shape mismatch 는
  broadcast/disk 양쪽 round-trip 을 눈으로 추적해야 보임.
  (2) "휴리스틱 pill 은 노이즈" 라는 디자인 판단이 deploy target 현실
  (macOS 15 ↔ FoundationModels macOS 26)을 무시. 가시 시그널이 절대 안
  나오는 사용자 그룹의 존재를 못 보면 review 가 잡아준다.

---

## 2026-05-10 — Stream Deck OpenClaw 버튼: 모델 별칭 + 크리처 확대 + 세션별 회전 위상

### 문제

Stream Deck (정사각 144×144 키패드) OpenClaw 세션 버튼에서 세 가지 거슬림이 보고됨:

1. **모델명 줄임표**: `claude-sonnet-4-6` 같은 긴 문자열이 `claude-sonnet…` 로
   잘려서 추하게 보임. D200H 의 모델 표기는 같은 라이브러리가 같은 자르기
   규칙을 쓰는데도 가로폭 덕분에 자연스러워 보임.
2. **크리처가 너무 작다**: 48 px 워터마크 + opacity 0.42 라 답답함.
3. **회전 애니메이션 동기화**: PROCESSING 버튼이 여럿 있을 때 전부 같은
   `animFrame` 으로 perimeter 위 동일 위상에서 돌아 — 실제 진입 시점이
   다른데도 lockstep 애니메이션이라 어색.

### 해결

- `aliasModelName(name)` 신규 헬퍼 (`shared/src/svg-renderers/session-slot-renderer.ts`):
  `^claude-([a-z]+)-(\d+)-(\d+)(?:-\d+)?$` 정규식으로 `claude-sonnet-4-6 →
  sonnet 4.6`, `claude-haiku-4-5-20251001 → haiku 4.5` 등 매핑. 매칭 안되면
  원본 그대로 반환. `formatModelEffort` 가 truncate 전 한 번 통과시킴.
- 크리처 워터마크 48 → 72 px, opacity 0.42 → 0.55 (working) / 0.54 → 0.62
  (idle). `agentLogoIcon` 이 size 인자만 받아 동적 scale 하므로 한 줄 변경.
  D200H 도 같은 `renderSessionSlot` 을 호출하므로 동일하게 반영됨 — 사용자
  가 D200H 표기를 "적당하다" 평한 만큼 회귀가 아닌 일관성 개선.
- PROCESSING/AWAITING 진입 시점의 `animFrame` 을 `processingStartFrame:
  Map<sessionId, number>` 에 캡처. `renderOrbitingRect` 의 `phasePx =
  -(startFrame * speedPx) % BORDER_PERIMETER` 로 변환해 세션마다 perimeter
  위 시작점이 달라짐. 진입 시점이 같으면 동기, 다르면 비동기 — 실제
  타이밍에 맞는 자연스러운 위상.

### 핵심 설계 결정

- **wire protocol 미변경**: `processingStartedAt` 같은 새 필드를
  `SessionInfo`/`StateUpdateEvent` 에 추가하지 않음. 플러그인이 자체
  `animFrame` 으로 진입 시점을 재구성해도 phase 계산엔 충분 — 동일 세션
  이 ID 만 같으면 다음 진입 시 새 startFrame 으로 리셋됨.
- **animation restart 시 map clear**: `startAnimation()` 이 `animFrame = 0`
  으로 리셋하는데 stale map 엔트리가 남으면 phase 가 어긋남. clear 한 줄
  추가로 해결.
- **`renderSessionSlot` 만 손대고 D200H 전용 렌더러는 건드리지 않음**:
  D200H 가 같은 shared 함수를 호출한다는 사실은 재발견의 연속. 첫 패스
  에선 D200H `renderInfoButton('MODEL', state.modelName.slice(0,12))` 두
  곳을 의도적으로 건드리지 않고 보고 — 사용자가 D200H 표기를 이미
  좋다고 했고 review item 이 Stream Deck 한정이라.
- **stop-time review 가 잡은 1차 누락**: 첫 commit 은 session-slot SVG
  renderer (`renderSessionSlot`) 와 `renderDetailInfo` 만 alias 통과시켰
  지만, `SessionSlotManager.modelStatusCard()` 와 OpenClaw model preset
  subtitle (line 612 부근) 은 raw `modelName` 을 직접 truncateStr 로
  넘기고 있었음. detail view 의 MODEL 카드 / preset 버튼이 alias 미적용.
  Codex stop-time review 가 "model alias is incomplete on Stream Deck
  detail model cards" 로 짚어 보강 — `aliasModelName` 을 manager 에
  import 해서 두 spot 에 적용. regression test 한 개 추가해 detail 레이아
  웃에서 status card / preset 둘 다 alias 결과를 직접 비교.

### 검증

- `pnpm test`: 1186 / 1186 passing (1185 + 신규 alias 테스트 7 + manager
  detail-alias 테스트 1 → snapshot 1 갱신).
- 런타임 sanity: `renderSessionSlot` 두 번 호출해 `processingStartFrame`
  4 프레임 차이로 stagger 시킨 결과 `dashoffset` 이 `-220` vs `-132` 로
  분리됨을 확인.
- 실기 검증 (Stream Deck 본체에 plugin link 후 OpenClaw 다중 세션 RUN
  타이밍 차로 띄워보기) 은 사용자 몫으로 남겨둠.

---

## 2026-05-10 — Timeline prompt visibility + anonymous Codex OTel noise cleanup

### 문제

Dashboard TIMELINE 이 "의미 있는 턴만 보인다"는 취지는 맞지만, 실제
사용자 prompt row (`chat_start`) 까지 완료 row 뒤에서 사라지는 경우가
있었다. `TimelineStripView.timelineDisplayGroups` 가 later completion 이
있는 모든 `chat_start` 를 숨겼기 때문에, `hello` 같은 짧지만 의도적인
요청은 응답이 정상 도착한 뒤 timeline 에서 직접 보이지 않았다.

동시에 `~/.agentdeck/timeline.json` 에 과거 익명 Codex OTel 세션
(`codex:otel-active`) 의 `tool`, `tool completed`, `exec` 같은 저신호
`tool_exec` 행이 남아 있었다. 이 행들은 durable thread/prompt 없이 단일
익명 세션에 붙어 task 경계 없이 섞였고, 사용자가 "내 요청은 안 보이고
중간 처리 로그만 보인다"고 느끼게 만들었다.

### 해결

- 완료된 턴이라도 `chat_start.raw` 가 실제 사용자 문장이라면 Dashboard
  timeline 에 유지한다. `Prompt sent`, `Codex turn started`, `Connected`,
  `Resumed` 같은 synthetic 시작 row 만 계속 접는다.
- `chat_response` 가 있는 경우 redundant `chat_end` 는 기존처럼 숨겨 한
  턴이 "요청 + 응답" 중심으로 보이게 했다.
- `DaemonTimelineStore` 가 anonymous Codex OTel 의 generic tool rows
  (`tool`, `tool completed`, `unknown`, `exec`, `exec completed`) 를 add/load
  양쪽에서 버리도록 했다. 기존 persisted history 도 앱 재시작 시 필터된다.
- 동일 필터를 Dashboard display path 에도 두어 오래된 history replay 가
  들어와도 UI 에 노출되지 않게 했다.

### 검증

- `git diff --check -- apple/AgentDeck/UI/Monitor/TimelineStripView.swift
  apple/AgentDeck/Daemon/Timeline/DaemonTimelineStore.swift
  apple/AgentDeckTests/TimelineTests.swift` 통과.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -destination 'platform=macOS,arch=arm64'
  -only-testing:AgentDeckTests_macOS/TimelineTests test
  CODE_SIGNING_ALLOWED=NO
  -derivedDataPath /tmp/AgentDeckTimelineInvestigation` 통과 — 22 tests,
  0 failures.

---

## 2026-05-10 — lobe-icons 브랜드 마크 다운스트림 렌더러 정리 (stop-hook iteration)

### 문제

`design/brand/` SSOT 와 `docs/design{,-mockups}/creatures.jsx` 는 lobe-icons
4개 마크(claudecode/codex/openclaw/opencode) 로 정리됐지만, 다운스트림
hardcoded path renderer 들이 옛 sourcing 그대로:
- Apple `CreatureClaudeCode.imageset/claudecode.svg` → lobe `claude.svg`
  (Anthropic swirl, generic Claude mark, Claude Code 전용 아님)
- Apple `CreatureOpenCode.imageset/opencode.svg` → 240×300 viewBox + baked
  color (`#CFCECD`/`#211E1E`) 자체 변형
- Android `BrandIcon.kt` `CLAUDE_PATH` = swirl, `OPENCODE_PATH` = 240×300
  → 24×24 스케일 path
- Apple `SessionBrand.AgentBrandIcon` `claudePath`/`openCodePath` 동일 stale
- `SessionListPanel.swift` 에 promoted 후 잔존한 dead `BrandIcon` struct
  (57 lines, swirl + 240×300 path 보유)

같은 4 브랜드를 표현하는 5개 표면(asset catalog / Compose Canvas / SwiftUI
Path / JSX inline embed × 2 mirror) 이 SSOT 변경 후에도 다르게 렌더되는
드리프트. Codex stop-hook 이 "updated SVG assets leave hardcoded brand
renderers stale" 로 잡았다.

### 해결

- Apple imageset 2개 → lobe-icons (`claudecode.svg` Claude Code-specific
  grid, `opencode.svg` 24×24 currentColor nested-square)
- Android `BrandIcon.kt` `CLAUDE_PATH`/`OPENCODE_PATH` → lobe 24×24 paths
- Apple `SessionBrand.AgentBrandIcon` `claudePath`/`openCodePath` → lobe 24×24
- `docs/design{,-mockups}/creatures.jsx` opencode entry 240×300 → 24×24 +
  주석 정리 (prior 세션이 일부 정리, 잔여 mirror 동기화)
- `SessionListPanel.swift` dead `BrandIcon` struct 제거
- `shared/svg-renderers/agent-logos.ts` `ROBOT_CREATURE_PATH` 는 이미 lobe;
  `CLAUDE_LOGO_PATH` (Anthropic swirl) 는 plugin/test 별도 reference 로
  stale 아님 — 그대로 유지

### 핵심 설계 결정

- **lobe-icons antigravity ↔ claudecode 분리.** 2026-04-19 의 Antigravity
  사고는 lobe-icons claudecode.svg 의 grid 패턴이 Anthropic swirl 과
  혼동된 것. 현재 lobe-icons 는 `antigravity.svg` = swirl/peak shape 로
  분리되어 있어 grid = Claude Code 가 정확. `<title>` 검증 게이트는 여전히
  필수 (Anthropic swirl 을 Claude Code 자리에 잘못 넣는 inverse error
  여전히 가능)
- **다중 표면 동기화 체크리스트.** 같은 SVG 가 inline embed 되는 5곳:
  (1) `apple/.../Assets.xcassets/Creature*.imageset/*.svg`,
  (2) `android/.../ui/component/BrandIcon.kt` path 상수,
  (3) `apple/.../UI/Common/SessionBrand.swift` `AgentBrandIcon` path 상수,
  (4) `docs/design{,-mockups}/creatures.jsx`,
  (5) `shared/src/svg-renderers/agent-logos.ts`. SVG 자산 변경 시 모두
  동기화 필요. 메모리 `brand-renderer-surfaces.md` 참고
- **Stylized vs canonical 구분.** `agent-logos.ts` 의 `openCodeCreatureIcon`
  은 nested rectangle primitive 로 그리는 SD button tile 렌더러로, 의도된
  warm-grey palette. brand mark pixel-accurate reproduction 아니므로 lobe
  SVG 변경에 동기화 불요. Terrarium creature renderers (CloudCreature,
  OpenCodeCreature, CrayfishCreature 등) 도 anim 캐릭터 표현체로 별도
- **Dead code 정리 동반.** Promoted-and-orphaned `BrandIcon` struct 같은
  zombie path 상수는 stale source 로 남기 쉬워 같이 제거. 식별 기준:
  struct 외부에서 생성자 호출 0건 + 기능적 후계자 존재

### 커밋 분리 메모

워킹 트리에 prior 세션의 거대한 미커밋 작업(timeline/daemon/Codex OTel 등
71 files +3642 lines)이 누적되어 있어, 이번 세션의 lobe-icons 변경 중
강결합 부분(`SessionBrand.swift` lobe path = prior 가 추가한
`AgentBrandIcon` 코드 안의 변경, `SessionListPanel.swift` dead struct 삭제
= prior +123 line 변경과 같은 파일)은 분리 staging 비현실적. 깨끗한 3개
파일(`Apple imageset claudecode.svg`/`opencode.svg` + `Android BrandIcon.kt`)
만 `e2f1377c` 로 커밋. 나머지 lobe-icons 변경은 prior 미커밋 묶음과 함께
다음 일괄 처리 시점에 합류 예정.

---

## 2026-05-10 — 프로젝트 전체 최소 OS = macOS 15+ 통일

### 결정

직전 commit 에서 App Store SwiftUI 앱만 deploy target 을 14 → 15 로
올렸지만, README badge / Requirements 표 / `docs/appstore-feature-matrix.md`
의 Min OS 행 / Stream Deck 플러그인 manifest 등은 여전히 "macOS 14+
Sonoma" 를 광고하고 있어 사용자가 보는 OS 라인이 두 갈래로 갈라져
있었다. 사용자 결정으로 프로젝트 전체 (CLI + App Store + Stream
Deck 플러그인 distribution) 최소 지원 OS 를 macOS **15.0+** 으로
일괄 통일.

### 변경 표면

- `README.md`: macOS 14 badge → 15, Requirements 표의 CLI/App Store
  split 행을 단일 `Platform: macOS 15+ (Sequoia)` 한 행으로 합침,
  Prerequisites 표의 macOS 14+ → 15+.
- `docs/appstore-feature-matrix.md`: Min OS 행의 CLI 칸 14 → 15.
- `plugin/bound.serendipity.agentdeck.sdPlugin/manifest.json`:
  `OS[0].MinimumVersion` 14.0 → 15.0. `Nodejs.Version: "20"` 은 SDK
  슬롯이라 별개로 유지.
- Swift 주석 두 곳 (`AgentStatusIcon.swift`, `AquariumSurface.swift`)
  의 "macOS 14+" 표현 정리.

코드 동작 변경 없음. `apple/project.yml` / pbxproj / CI runner /
Swift availability 게이트는 이미 직전 작업에서 macOS 15 로 정렬됨.

### 영향

- macOS 14 (Sonoma) 사용자는 다음 release 부터 AgentDeck CLI 와
  Stream Deck 플러그인 모두 신규 install 대상에서 제외된다 (App
  Store 빌드는 이미 직전 release 부터).
- 이전 entry 에서 적은 "feature matrix 가 잘못된 CLI runtime 을
  기록 (Node 18 / macOS 13)" Codex stop-time finding 도 본 통일로
  자동 해소.

### 검증

- `grep -rn "macOS 14\|Sonoma" README.md docs/ apple/AgentDeck plugin/`
  → DEVELOPMENT_LOG history 외 잔존 0 건.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme
  AgentDeck_macOS -destination "platform=macOS,arch=arm64" build`
  통과 (BUILD SUCCEEDED).
- `python3 -c "import json; json.load(open('plugin/bound.serendipity.agentdeck.sdPlugin/manifest.json'))"`
  통과.

---

## 2026-05-10 — Codex OTel HUD / project label / timeline noise 정리

### 문제

Codex App / app-server OTLP batch 가 durable `thread.id` 없이 trace-backed
span 만 보내는 경로에서 Dashboard 는 `codex:otel-active` 익명 세션을 만들었다.
이 세션은 cwd 도 비어 있어 좌측 HUD 프로젝트명이 빈 문자열로 보였고, 같은
OTel batch 의 내부 `tool_call` / `tool_result` span 이 그대로 TIMELINE 에
`tool`, `tool completed` 같은 저품질 로그로 노출됐다. 또한 좌측 HUD 행은
asset catalog SVG template 렌더링을 16pt 슬롯에 직접 써 Codex compact glyph
가 깨져 보일 수 있었다.

### 해결

- 좌측 HUD agent glyph 를 asset renderer 대신 `AgentBrandIcon` path renderer
  로 통일하고, Codex path 를 canonical `design/brand/codex.svg` 와 맞췄다.
- Codex OTel cwd alias 를 `process.cwd`, `terminal.cwd`, `workspace.root`,
  `project.root` 등으로 확장했다.
- 익명 `codex:otel-active` 세션은 cwd 가 없을 때 현재 visible session 들의
  non-Codex 프로젝트명이 하나로 수렴하면 그 이름을 fallback 으로 채운다.
  그래도 알 수 없으면 HUD 표시는 `Codex` 로 fallback 해서 빈 라벨을 피한다.
- OTel tool spans 는 타임라인에 쓰지 않고 세션 state/currentTool 갱신에만
  사용한다. Codex timeline 은 실제 prompt/response payload 가 있는 lifecycle
  hook 에서만 기록한다.
- `tool` / `unknown` 처럼 의미 없는 Codex tool name 은 hook path 에서도
  timeline entry 로 만들지 않는다.

### 검증

- `git diff --check -- apple/AgentDeck/UI/Common/SessionBrand.swift
  apple/AgentDeck/UI/Monitor/SessionListPanel.swift
  apple/AgentDeck/Daemon/Modules/CodexTelemetryModule.swift
  apple/AgentDeck/Daemon/Server/DaemonServer.swift
  apple/AgentDeckTests/CodexOtelParserTests.swift`
  통과.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -destination 'platform=macOS'
  -only-testing:AgentDeckTests_macOS/CodexOtelParserTests
  -only-testing:AgentDeckTests_macOS/ProtocolTests test
  EXCLUDED_SOURCE_FILE_NAMES=LaunchSessionDialog.swift CODE_SIGNING_ALLOWED=NO
  -derivedDataPath /tmp/AgentDeckCodexHudTimelineFix`
  통과 — 52 tests, 0 failures.

---

## 2026-05-10 — Menubar 패널 정리 + macOS deploy target 15.0

### 문제

메뉴바 ControlTowerPanel 에 누적된 6 가지 사용자 체감 결함:

1. OpenClaw 행에 uptime 이 `20582d` 로 표기 — 가상 `openclaw-gateway`
   세션이 `startedAt` 을 `1970-01-01` placeholder 로 인젝트해서
   `displayRelativeTime` 이 epoch 차이를 일 단위로 계산.
2. TOPOLOGY hub 의 `AgentDeck` 과 `:9120` 이 두 줄로 쌓여 popover
   세로공간 낭비.
3. 패널이 `.frame(height: 620)` 로 고정돼 디바이스 수와 무관하게 동일
   높이 + 내부 ScrollView 가 항상 활성.
4. Launch Session 진입점 (pill, empty-state 버튼, MonitorEmptyGuide
   카피, Window scene, dialog 파일) 이 dead path 로 잔존.
5. Dashboard 가 단순 `openWindow(id:)` 만 호출 — 메뉴바에서 닫을 수
   없고 시각적 활성/비활성 표시 없음.
6. Evaluation/Settings 가 macOS window-restoration 으로 재실행 시
   다시 떠 사용자 의도와 어긋남.

### 해결

- **OpenClaw uptime**: `DaemonServer` 에 `gatewayConnectedAt: Date?`
  필드 추가, connect 시점 기록 + 모든 disconnect 경로에서 nil 클리어.
  `buildSessionsListEvent` 가 nil 이면 `startedAt` 를 dict 에 넣지
  않아 시간 chip 이 그냥 안 보이도록 함.
- **TOPOLOGY 한 줄**: `MenuBarTopologyList.hubNode` 의 inner
  `VStack(spacing: 0)` → 단일 `HStack(.firstTextBaseline)`. 포트 폰트
  9.5→11pt 로 가독성 보강.
- **DOWNSTREAM 동적 사이즈**: ControlTowerPanel `height: 620` 제거,
  inner VStack 에 `.fixedSize(vertical: true)`, ScrollView 외부에
  `.frame(maxHeight: scrollContentMaxHeight)` (= visibleFrame * 0.85
  - 140pt chrome). 디바이스가 적으면 패널이 줄고, 30+ 인 극단에서만
  내부 스크롤이 등장.
- **Launch 제거**: ControlTowerPanel 의 pill·empty-state 버튼·helper
  4 곳 + AgentDeckApp 의 `Window("Launch Session")` scene + MonitorEmptyGuide
  의 Launch 버튼/카피 + `LaunchSessionDialog.swift` 파일 + APP_REVIEW_NOTES
  / AquariumSurface 주석까지 일괄 정리. companion-install prompt 위반
  소지를 차단하기 위해 빈 sessions 카피도 "Sessions appear here
  automatically once the bridge picks one up." 식의 neutral 문구로 통일.
- **Dashboard 토글**: `@State dashboardVisible` + 5s timer +
  NSWindow Notification 4종 (didBecomeKey/willClose/didMiniaturize/
  didDeminiaturize) 옵서버. pill 이 활성 시 `Dashboard ●` (primary
  fill) / 비활성 시 `Dashboard` (outline). 클릭으로 open/close 토글.
- **Evaluation/Settings 자동 복구 차단**: 두 `Window` scene 에
  `.defaultLaunchBehavior(.suppressed)` 적용. 이 modifier 가 macOS 15+
  전용이라 deploy target 을 14 → 15 로 상향. 동시에 AquariumSurface
  의 `if #available(macOS 15.0, *)` 게이트 1 곳 정리.

### macOS 15.0 deploy target 변경의 영향

- **사용자 영향**: macOS Sonoma (14.x) 에 머무는 사용자는 다음 빌드
  부터 App Store 업데이트를 받지 못한다. CI 는 이미 `macos-15` runner
  사용 중이라 빌드 인프라는 그대로.
- **iOS deploy target (17.0) 은 변경 없음** — iOS companion 영향 없음.
- **App Store Connect 의 minimum OS 표기**도 다음 release 업로드 시
  자연 반영. 메타데이터·release notes 는 본 차수 업로드 직전에
  "Requires macOS Sequoia 15 이상" 명시 필요.
- **하향 호환 경로**: `defaultLaunchBehavior(.suppressed)` 만 떼면
  14 호환은 회복 가능. 다만 6번 이슈는 AppDelegate
  `applicationDidFinishLaunching` 의 windows orderOut fallback 이 또
  필요해진다.

### 검증

- `xcodegen` 으로 project.yml → pbxproj 재생성, `MACOSX_DEPLOYMENT_TARGET = 15.0`
  두 build config 모두 반영 확인.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -configuration Debug -destination "platform=macOS,arch=arm64" build`
  통과 (BUILD SUCCEEDED, 경고 없음, App Store Helper Guard 통과).
- `grep -rn "launch-session|openLaunchSession|LaunchSessionDialog" apple/` → 0 건.
- Codex adversarial review (verdict: needs-attention) — 두 high finding
  (timeline dedup, focus_session validation) 은 본 PR 영역 밖 선행
  변경 결함으로 분리. macOS 14 drop 은 본 entry 로 의도 + 영향 명시.

---

## 2026-05-10 — Codex 구독 만료일 relay 보강

### 문제

App Store 샌드박스 macOS daemon 은 프로젝트 invariant 상 `~/.codex/auth.json`
을 직접 읽지 않는다. 그래서 Codex Observation/OTel 로 세션은 보이더라도
ChatGPT plan / `chatgpt_subscription_active_until` 은 비어, Dashboard 의
Subscriptions footer 에 Codex 만료일이 나타나지 않았다.

### 해결

- 터미널/Node bridge 가 `usage_update` 로 보낸 Codex auth metadata 를
  Swift daemon 이 캐시하고, 이후 daemon 소유 `state_update` / `usage_update`
  에 다시 싣도록 했다.
- `subscriptions` 배열이 없는 이벤트라도 `codexPlanType` /
  `codexSubscriptionActiveUntil` 이 있으면 Swift state layer 가 ChatGPT
  subscription row 를 합성한다.
- `state_update` 도 Codex auth metadata 를 디코드하도록 protocol model 을
  확장했다.

### 검증

- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS
  -destination 'platform=macOS' -only-testing:AgentDeckTests_macOS/ProtocolTests
  -only-testing:AgentDeckTests_macOS/CodexOtelParserTests test
  EXCLUDED_SOURCE_FILE_NAMES=LaunchSessionDialog.swift CODE_SIGNING_ALLOWED=NO
  -derivedDataPath /tmp/AgentDeckCodexSubscriptionRelayTests`
  통과 — 50 tests, 0 failures.

## 2026-05-10 — Menu bar AgentDeck symbol alignment

### 문제

macOS 메뉴바 라벨과 드롭다운 팝업의 AgentDeck 허브 아이콘이 추상적인
stacked-card mark 를 사용했다. App Store / Dock / pairing splash 의 실제
제품 상징인 aquarium dome + hardware deck 아이콘과 실루엣이 달라,
같은 앱 안에서도 메뉴바 표면만 별도 브랜드처럼 보였다.

### 해결

- `AgentDeckLogo` 를 app icon 의 작은 크기용 심볼로 재정의했다. 전체
  bitmap illustration 을 16–20pt 로 축소하지 않고, dome outline /
  waterline / deck base / button glints 만 SwiftUI Shape 로 그린다.
- 메뉴바 상태 배지와 calm header 연결 상태 색을 `DesignTokens.UI.*`
  product palette 로 이동했다.
- `DESIGN.md` §6 에 small-size product symbol 규칙을 추가하고, 메뉴바
  아이콘 규칙을 현재 구현과 맞췄다. 추상 card-stack / hub / router
  mark 는 production AgentDeck mark 로 쓰지 않는다.

### 검증

- `xcodebuild -scheme AgentDeck_macOS -destination 'platform=macOS' build
  EXCLUDED_SOURCE_FILE_NAMES=LaunchSessionDialog.swift` BUILD SUCCEEDED.
  현재 워크트리의 별도 변경에서 `LaunchSessionDialog.swift` 가 삭제됐지만
  `AgentDeck.xcodeproj` 의 stale reference 가 남아 있어, 일반 빌드는 그
  참조에서 먼저 중단된다.
- `python3 design/verify-tokens-sync.py` All mirrors in sync.
- `git diff --check -- apple/AgentDeck/UI/MenuBar/AgentDeckLogo.swift
  apple/AgentDeck/UI/MenuBar/AgentStatusIcon.swift
  apple/AgentDeck/UI/MenuBar/AttentionTheaterView.swift DESIGN.md
  DEVELOPMENT_LOG.md` 통과.

---

## 2026-05-09 — Focused session timeline filter + OpenClaw focus

### 문제

Dashboard 에서 특정 세션을 focus 해도 하단 `TIMELINE` 은 전체 세션 이벤트를
계속 섞어서 보여줬다. 동시에 OpenClaw crayfish 는 hit-test 결과가
`crayfish` sentinel 로 돌아오고 `MonitorScreen` 이 이를 무시해서, 좌측
HUD row 와 달리 terrarium 에서는 선택할 수 없었다.

### 해결

- `TimelineStripView` 가 `DashboardState.focusedSessionId` 를 읽어 해당
  세션의 timeline entry 만 필터링하도록 했다. 우선 `sessionId` 를 쓰고,
  legacy entry 는 `projectName + agentType` fallback 으로 매칭한다.
- OpenClaw virtual Gateway session (`openclaw-gateway`) 은 daemon-local
  session 으로 취급한다. crayfish tap 이 이 id 로 focus 하고, Swift/Node
  daemon 모두 relay WS 연결 없이 `focusedSessionId` 를 broadcast 한다.
- Terrarium focus halo 가 `openclaw-gateway` focus 를 crayfish 위치에
  그리도록 확장했다.

---

## 2026-05-09 — Dashboard 세션 행 UX: Jump grid 제거 + focus halo

### 문제

macOS Dashboard 좌측 HUD 의 세션 행을 클릭하면 5셀 (메뉴바) / 4셀 (Dashboard)
"Jump to..." 아이콘 그리드 (iTerm / VS Code / Cursor / Finder / Dashboard)
가 펼쳐졌지만 모든 셀이 dead UI: `SessionInfo` 에 `projectPath` 가
hook payload 에서 안 흘러와 모든 셀이 "앱을 빈 상태로 켤 뿐". Dashboard
셀은 메뉴바 헤더의 별도 "Dashboard" pill 버튼과 완전 중복.

그리드를 제거한 뒤 새로운 critique: row tap 이 invisible. `focus_session`
명령은 daemon 의 routing 셀렉터일 뿐이라 (model/effort 라벨, AttentionTheater
question, D200H 버튼 0, option 응답 라우팅) awaiting 세션이 0 일 때
사용자 측 시각 피드백이 0. "row tap 이 의미 있나?" 라는 인상이
재발생.

### 해결

**1단계: Jump grid 통째 제거** (commit `c37e9526`)

- `SessionJumpRow.swift` — `expanded` / `onToggle` / `onJumpDashboard` /
  `onJumpExternal` 4 콜백 → 단일 `onFocus` 로 축소. `JumpTarget` enum,
  `jumpCell()`, `SessionJumpLauncher` 통째 삭제. chevron rotation +
  "JUMP TO" 헤더 + 5 셀 그리드 제거.
- `SessionListPanel.swift` — `expandedSessionId` State + `stableId(suffix:)`
  + `jumpGrid(for:)` 삭제. `sessionRowInteractive` 를 단일
  `Button { focusSession }` 로 평탄화.
- `ControlTowerPanel.swift` — `expandedSessionId` State + 그것을
  정리하던 `.onChange(...)` collapse 블록 삭제. 콜사이트 4 콜백 →
  `onFocus` 단일.

**2단계: Terrarium focus halo** (commit `80095e8b`)

- `TerrariumState.focusedSessionId: String?` 신설.
- `DashboardState.toTerrariumState` 의 cloud-folding 루프에서 focused
  thread id 가 folded 상태이면 representative.id 로 재매핑
  (`resolvedFocusId`). 이래야 Codex 가 fold 된 상황에서도 보이는 cloud
  sprite 에 halo 가 붙음.
- `TerrariumRenderer` 에 `focusedSessionId` / `focusPulse` /
  `focusPresence` 상태 추가. `update(dt:state:)` 에서 hasVisibleFocus
  (octopus / cloud / opencode dict 에 id 존재) 를 검사해 presence target
  0/1, dt * 6.0 lerp 로 페이드. `drawFocusHalo()` 가 Layer 6.7 (Layer 6.5
  back-tetra 이후, Layer 7 crayfish 이전) 에서 cyan disc + neon ring 을
  pulse 와 함께 그림. `presence > 0.01` 가드로 페이드아웃 후 자동 skip.

### 핵심 설계 결정

**Jump grid 의 진짜 부재 원인**: `projectPath` 가 hook payload 에 없음.
정식 "이 세션의 터미널로 점프" 는 hook 측 `TERM_PROGRAM` /
`ITERM_SESSION_ID` / tty / parent_pid 캡처 + Swift 측 `NSAppleScript`
기반 jump (iTerm + Apple Terminal 만 현실적, VSCode/Cursor 통합 터미널은
본질적으로 외부 제어 불가) + `NSAppleEventsUsageDescription` entitlement
가 필요. App Store 빌드 호환 (subprocess 없음, in-memory `NSAppleScript`).
이는 별개 PR 로 분리 — dead UI 를 placeholder 로 유지하지 않음.

**focus halo 는 Layer 6.7**: back-layer fish (Layer 6.5) 위, crayfish
(Layer 7) 아래. 모든 creature (octopus / cloud / opencode) 보다 뒤에
그려져 sprite 가 halo 안에 깔끔히 앉음. Bubbles / front-tetra / water
surface 보다는 뒤라 환경 효과를 가리지 않음.

**presence envelope 가 핵심**: 단순 boolean on/off 면 세션 list 가
재정렬되거나 creature 가 잠깐 사라졌다 돌아올 때 halo 가 깜박임. `dt *
6.0` lerp (~166 ms 시정수) 로 부드럽게 페이드. `hasVisibleFocus` 검사로
focus 가 있어도 sprite 가 없으면 즉시 페이드아웃.

**Codex fold 해소를 mapping 단계에서 처리**: renderer 가 `[String: String]`
alias map 을 따로 들고 다닐 필요 없이, cloud-folding 루프 안에서
`members.contains(focused) → resolvedFocusId = representative.id`. 단일
필드로 끝.

---

## 2026-05-09 — Pixoo 설정 즉시 반영 (5s 폴링 우회)

### 문제

App Store Swift 데몬에서 Settings → Pixoo UI 로 IP 추가 후 Dashboard 우측
HUD (TopologyRail) 에 디바이스 타일이 즉시 안 나타남. 진단 결과 데몬은
정상 활성화되어 `/health` 가 `online: true, hasFrame: true` 를 즉시
반영하지만, `PixooModule.settingsReloadIntervalSec = 5` 로 settings.json
을 5초 주기 폴링만 함 (file watcher 없음). UI 가 파일을 쓴 시점부터
다음 폴링 tick 까지 0–5 초 + probe 1 회 만큼 HUD 가 비어 있음.

추가 진단 메모: 사용자의 기존 `~/.agentdeck/settings.json` 에 등록된 IP
는 App Store 샌드박스 데몬이 못 읽음 — 컨테이너의
`Library/Containers/.../AgentDeck/settings.json` 만 봄. 별개 이슈로 이
재설정은 사용자가 UI 에서 다시 추가해 해소.

### 해결

- `PixooModule.reloadFromSettingsExternal()` 추가 — 외부에서 폴링
  cadence 우회용 진입. 내부 `reloadDevicesFromSettings(reason: "ui-trigger",
  force: true)` 호출. `force` 는 파일이 새로 쓰였지만 IP 셋이 동일한
  엣지 (brightness 등) 를 위해 line 462 equality guard 우회.
- `Notification.Name.pixooSettingsChanged` 정의 + `DaemonServer` 가
  `pixooModule` 인스턴스화 직후 main queue observer 등록, 알림 시
  `Task { await pixoo.reloadFromSettingsExternal() }` 디스패치.
- `PixooSheet.addDevice` / `removeDevice` 가 `saveDevices()` 성공 직후
  `NotificationCenter.default.post(name: .pixooSettingsChanged)` 송출.
- `DaemonServer.shutdown()` 에서 `removeObserver` 처리.

### 핵심 설계 결정

NotificationCenter 로 UI ↔ daemon 단방향 시그널. PixooSheet 가
PixooModule ownership 을 알 필요 없고, 같은 패턴이 다른 settings 화면에
확장 가능. DispatchSource file watcher 도입은 범위가 크고 (모든 모듈
공통화 필요) 본 패치 범위에서 제외. 5 초 폴링은 fallback 으로 유지 —
외부 파일 편집 (terminal vim 등) 은 여전히 catch up.

### 후속 1 — broadcastStateUpdate 누락 (Codex stop-time review)

첫 패치는 actor 의 `devices` 와 shadow 만 업데이트하고 끝나서 `/health`
HTTP 는 즉시 반영됐지만, WebSocket 으로 `state_update` 이벤트가 안
나가 TopologyRail 같은 WS 구독자들은 다른 broadcast 가 발생할 때까지
stale 했다. observer 클로저에 `await pixoo.reloadFromSettingsExternal()`
직후 `Task { @MainActor in self?.broadcastStateUpdate() }` 추가
(commit `efd725b3`). actor 내부 snapshot 갱신과 WS broadcast 는 별도
신호임을 잊지 말 것.

### 후속 2 — 폴링 경로도 broadcast 누락 (Codex stop-time review 2회차)

후속 1 은 UI 트리거 경로만 broadcast 를 채웠고, 5초 폴링이
settings.json 외부 편집을 감지해 reload 하는 경로는 그대로 stale.
fallback path 가 거짓이었다. 해법은 actor 내부에 `onStateChanged:
(@Sendable () -> Void)?` 콜백을 두고 `reloadDevicesFromSettings` 의
두 terminal branch (devices.isEmpty / 정상) 에서 호출. DaemonServer
가 `await pixoo.setOnStateChanged { Task { @MainActor in
self?.broadcastStateUpdate() } }` 로 wiring. UI / 폴링 두 경로 모두
같은 hook 으로 broadcast (commit `e5ef66a8`). UI 옵저버 클로저는
`reloadFromSettingsExternal` 만 호출하도록 단순화 — 이중 broadcast
방지.

### 후속 3 — broadcast 가 prepareDevice 뒤로 밀림 (Codex stop-time review 3회차)

후속 2 의 `onStateChanged?()` 위치가 `prepareDevice` 루프 **뒤**라,
디바이스마다 HTTP RTT (timeout 2s × N) 동안 broadcast 가 blocking.
unreachable Pixoo 시 HUD 가 그 시간만큼 비어 있음. configuration 자체는
device list 갱신 시점에 이미 결정 — `devices` 할당 + `refreshShadow()`
직후 즉시 1차 broadcast, prepareDevice 루프 후 online/probed 상태 반영해
2차 broadcast 로 분리 (commit `344f6123`). configuredDeviceCount /
deviceIps 는 즉시, online / failures 는 RTT 후.

### 후속 4 — circuit breaker 전환 broadcast 누락 (Codex stop-time review 4회차)

후속 3 까지도 `onStateChanged?()` 는 `reloadDevicesFromSettings` 콜패스
안에서만 발사. `recordPushFailure` 가 `consecutiveFailures` 를 누적해
threshold 6회를 넘기며 `online=false` 로 flip 시킬 때, `recordPushSuccess`
의 recovered 분기, `probeBackedOffDevices` 재진입 등은 `refreshShadow()`
만 호출하고 broadcast 안 됨 → unreachable Pixoo 가 HUD 에서는 영원히
online 으로 보임. trigger 를 `refreshShadow()` 자체로 이동, user-visible
필드 digest 비교로 변경 시에만 발사. `reloadDevicesFromSettings` 의
explicit `onStateChanged?()` 호출은 redundant 라 제거 — refreshShadow 가
알아서 diff 검사 (commit `f748a042`).

### 후속 5 — hasFrame transition 누락 (Codex stop-time review 5회차)

후속 4 가 digest 에서 `lastPushAtMs` 와 함께 `hasFrame` 도 같이 제외함.
이게 잘못 — `lastPushAtMs` 는 333ms 렌더 tick 마다 변하지만, `hasFrame`
은 `false→true` (첫 프레임 push) 와 `true→false` (디바이스 0 으로 가서
`writeFrame(nil)` 호출) 두 transition 만 있는 user-visible 상태. 둘을
한 묶음으로 본 게 실수. `hasFrame` 을 digest 에 다시 포함해 first-frame /
device-removal 시점이 broadcast 되도록 복구. `lastPushAtMs` 만 단독
제외 (commit `357a6353`).

### 검증

- `xcodebuild -scheme AgentDeck_macOS -destination 'platform=macOS' build`
  BUILD SUCCEEDED (여섯 번 모두).
- 런타임 검증은 사용자가 새 빌드 재실행 후 ① Settings UI 에서 IP
  추가/제거 → swift-daemon.log `Pixoo ui-trigger: N configured device(s)`
  + 우측 HUD <1 초 내 변화 (네트워크 도달성과 무관), ② terminal 에서
  settings.json 수동 편집 → `Pixoo settings reload: ...` 로그 + WS
  구독자도 ≤ 5 초 내 반영, ③ unreachable IP 추가 시 HUD tile 즉시
  표시 → 6 회 push 실패 후 HUD 에서 online=false 로 전환, ④ 디바이스
  power-cycle 후 자동 recovery 도 즉시 broadcast, ⑤ 첫 프레임 push 시
  `hasFrame=false→true` transition 도 broadcast 에 잡힘.

---

## 2026-05-08 — Codex hook protocol on Node bridge (PTY-only state machine retired)

### 문제

지난 세션의 작업 ("Codex timeline task unit alignment with APME turn",
2026-05-06) 은 `wireAgentApme` 의 Codex 세그먼트가 7개 상태 변수 · 5개
helper · 3개 timer 로 부풀어 7회 Codex review iteration 후에야 layered
correctness 도달했다. 본질은 PTY 신호의 모호성 (status line `›`, mid-tool
silence, stale frame, sub-second redraw) 을 단일 state machine 으로 보상
하느라 그런 것.

리서치 결과: **Apple Swift daemon 은 같은 문제를 hook 기반으로 깔끔하게
처리하고 있었다**. `apple/AgentDeck/Daemon/Core/CodexConfigInstaller.swift`
가 `~/.codex/config.toml` 에 `[features] codex_hooks = true` + 5개
lifecycle hook 표를 install. 그러나 **Node bridge (CLI) 측에는 동등 인프라
0건** — `bridge/`, `hooks/`, `setup/` 어디에도 `codex_hooks` 매치 없음.
Node CLI (`agentdeck codex`) 사용자에게는 PTY 출력만이 turn-boundary
신호여서 wireAgentApme 의 복잡한 state machine 이 필요했던 이유.

### 해결

**Node bridge 에 Codex hook 인프라 포팅 + state machine retire**.

**Phase A — Codex hook installer 포팅** (commit 7553e11c, ~650 LOC):
- `hooks/src/codex-mini-toml.ts` — `apple/AgentDeck/Daemon/Core/MiniToml.swift`
  의 lossless TOML 편집을 직접 포팅. `@iarna/toml` 같은 semantic parser
  쓰지 않음 — 사용자 TOML 의 key 순서/들여쓰기/blank line 을 normalize 해
  버리면 round-trip-preserves-user-config invariant 깨짐. fence sentinels
  byte-identical.
- `hooks/src/codex-install.ts` — install/uninstall/migrate. PORT-resolution
  shell snippet 은 `hooks/src/install.ts:buildHookCommand` 와 byte-identical
  (Apple/Node 두 daemon 이 같은 fence 를 toggle 가능). conflict gate (사용자
  authored `[features]` / `[hooks]` / `notify` / `[otel]` 외부 → install
  skip). Atomic write.
- Trigger 2 곳: `agentdeck codex` action lazy install + `agentdeck daemon
  install`. setup 패키지는 zero-runtime-deps inline 패턴 보존을 위해 skip
  — 사용자가 `agentdeck codex` 첫 실행 시 어차피 trigger.
- 29 unit tests (Apple `MiniTomlTests` + `CodexConfigInstallerTests` 1:1
  포팅 + round-trip preserve invariant pin).

**Phase B — Bridge hook 처리** (commit ed67a5ce, ~240 LOC):
- `bridge/src/state-machine.ts` 에 codex_* case 6개 추가 (session_start,
  user_prompt_submit, tool_start, tool_end, stop, turn_complete). Claude
  trigger 라벨 (`'session_start'` etc.) 재사용해 `shared/src/states.ts`
  transitions 테이블에 codex 접두사 안 추가.
- `bridge/src/apme/adapters/codex-hook.ts` — `claude-hook.ts` 의 mirror.
  `codex_user_prompt_submit → turn_start`, `_tool_start → tool_call`,
  `_tool_end → tool_result`, 나머지 raw_step. `/clear` 감지.
- `bridge/src/index.ts` hook branch 에서 `agentType === 'codex-cli'` 시
  `codexHookToSpans` 사용 (Claude 와 분기).
- 7 state-machine + 5 codex-hook span 테스트.

**Phase C — CodexTurnManager class 추출** (commit 39e169fc, +600/−280 LOC):
- `bridge/src/apme/adapters/codex-turn-manager.ts` 신규 class.
  `onHookEvent` (primary), `onParserEvent` (PTY fallback), `cleanup`.
- 30s `hookActive` freshness window — 최근 hook 이 fired 면 PTY parser idle/
  spinner_stop 신호 demoted (turn-boundary no-op). 그 윈도우 밖에서만
  legacy state machine 활성. Hook 이 발화 안 하는 사용자 (CLI 만 + hooks
  install 거부 또는 Codex Ink-TUI repaint glitch) 에 대비한 fallback 유지.
- Hook path 는 timeline-only — APME ingestion 은 index.ts hook adapter 가
  이미 codexHookToSpans 로 처리. 재 ingestion 하면 turn 이 닫혔다 다시
  열려 망가짐.
- `classifyAndEnqueueTurn` 을 `apme/classify-turn.ts` 로 추출 (codex-turn-
  manager 가 import 시 index.ts 와 circular dep 안 만들게).
- wireAgentApme 의 305-line Codex 세그먼트가 ~25 LOC 로 축소.
- 7 단위 테스트 (hook happy path, multi-turn fresh chat_starts, hook
  freshness window, long-bash with multiple tool pairs, PTY-only fallback
  3 cases).

**Stop-gate fixes (Codex review):**

**b2fc3bc5 — `codex_stop` 가 APME turn finalize 안 됨**:
- collector.closeTurn 이 private + UserPromptSubmit/closeRun 만 호출 →
  codex_stop 후 다음 prompt 까지 turn 의 `endedAt` null + tool_calls 미flush.
- `ApmeCollector.closeTurnForSession(sessionId)` public wrapper 추가,
  `CodexTurnManager.closeTurn` 마지막 (chat_response/classify 후) 에 호출.

**88ef6b23 — closeTurnForSession 이 turn_index sequence reset**:
- `collector.ingestHook` UPS 분기가 `sessionToTurn.get(sessionId)?.index`
  로 prevIndex 읽음. 내가 codex_stop 에서 sessionToTurn 비우니 다음 UPS
  시 prevIndex=-1 → turnIndex=0 으로 reset → 모든 후속 turn 의 index 가
  0 → tasks.firstTurnIndex/lastTurnIndex 부기 깨짐.
- sessionToTurn 비면 `sessionToLastTurnId` 로 fallback 해서 store 에서
  마지막 turn 의 turn_index 읽기. Claude/OpenCode 는 closeTurnForSession
  안 부르니 영향 없음.

### 핵심 설계 결정

- **Hook protocol 이 있으면 PTY parser 보다 항상 우선**. 모호성 없는 명시
  signal 이 추측보다 monotonically 안전. PTY 는 fallback (hook miss 18%
  케이스) 으로만 유지.
- **30s freshness window 로 mode 전환**. hook 이 한 번이라도 떴으면 30s 간
  hook-primary, 그 후 미수신이면 PTY-fallback 자동 재진입. 운영 환경에
  따라 (사용자 hooks install 안 함, Codex 버전 차이) 자동 적응.
- **fence sentinel + PORT-resolution shell snippet 을 Apple/Node 동일하게**.
  `~/.codex/config.toml` 을 두 daemon 이 toggle 해도 conflict 없음. hook
  command 가 매 호출 시점에 active daemon 자동 매칭.
- **Setup package 는 zero-runtime-deps 인라인 패턴 보존**. Codex install
  은 `agentdeck codex` 첫 실행 lazy trigger 로 충분 — setup 직후 사용자도
  결국 codex 명령 통해 trigger 받음.
- **`codex_stop` 는 turn 을 즉시 finalize**. Claude `Stop` 은 finalize 안
  하고 다음 UPS 에 prev close 하는데, Codex 의 codex_stop 이 더 강한
  "turn 끝" 의미라 즉시 finalize 가 맞음. 단, prevIndex 읽기 fallback 추가
  필요했음.

### 검증

- `pnpm test`: 1138 passing (기존 1086 + Phase A 29 + Phase B 12 + Phase C
  8 + finalization 1 + index regression 1 + 기존 테스트 추가 가산).
- `pnpm -r build` clean. typecheck clean.
- 수동 검증은 다음 세션에서 — 실제 `agentdeck codex` 세션을 띄워:
  - `~/.codex/config.toml` fence-block 자동 install 확인
  - Apple/Android tablet timeline 에서 multi-bash prompt 가 chat 카드 1개로
    표시되는지 (long bash > 15s 케이스 포함)
  - `apme.sqlite` 의 Codex run turns 가 monotonic turn_index, 정확한
    tool_calls, response, endedAt 채워져 있는지

### 회고

이번 작업은 두 세션에 걸친 7+5회 Codex review iteration 의 결과. 첫 세션
(2026-05-06) 의 PTY-only state machine fix 가 layered correctness 도달후,
이번 세션이 그 layering 을 hook 기반으로 retire 시켰다. 같은 영역의 review
가 반복해서 다른 layer 의 bug 를 짚는 패턴은 `feedback_codex_stoptime_iteration`
메모대로 — "이미 고쳤음" 으로 무시 금지. 매 commit 이 separate.

---

## 2026-05-08 — HUD primary-only OpenClaw + ChatGPT subscription parity (3-round stop-gate)

### 문제
- 우측 HUD Upstream 의 OpenClaw 줄에 모델 카탈로그 전체가 노출. 사용자는 primary (`role=="default"`) 만 원함.
- macOS 에서 ChatGPT subscription 행이 아예 안 떴고, Android 태블릿은 이미 지나간 `subscription_active_until` 을 그대로 표시.

### 해결
- `apple/AgentDeck/Daemon/Server/DaemonServer.swift` — `buildFullStateEvent` 가 `buildSubscriptions()` 를 호출하도록. 이전엔 `buildUsageEvent` 에만 들어가 SwiftUI 의 `state.subscriptions` 가 비어 있었음.
- `apple/AgentDeck/Model/Protocol.swift` `openClawDisplayLines` + Android `TopologyRail.kt` / `EinkStatusPanel.kt` / `EinkStatusCompact.kt` — `role == "default"` 만 통과시키는 strict filter. fallback-N / configured 는 모두 숨김. **default 가 없으면 row 자체를 비움** (사용자 명시 선택을 그대로 따름).
- 만료 처리: `subscriptionTrailing(until, now)` 헬퍼 — future date 는 `~yyyy-MM-dd`, 과거/parse 실패는 `renewal needed` 라벨로 전환. macOS 는 `TerrariumHUD.ledAmber`, Android 는 `TerrariumColors.LEDAmber` 로 강조.
- **Time invalidation (Android 전용)**: `rememberCurrentInstant(periodMillis = 60_000L)` Compose 헬퍼 도입 — `produceState` 로 60s tick 을 발생시켜 expiring window 가 자동으로 "renewal needed" 로 전환. 이전엔 `Instant.now()` 를 view body 에서 직접 읽거나 `remember(subs) { Instant.now() }` 로 캐시 → recomposition 트리거 없이는 갱신 안 됨.
- `parseUntilDate` (Apple) — `^\d{4}-\d{2}-\d{2}$` regex gate + `isLenient = false` 추가. 기존엔 `2026/05/06` 같은 형식이 silent 통과.
- 테스트: `OpenClawDisplayLinesTest` 신규 (Android), `TopologyRailHelpersTests` 에 `subscriptionTrailing` 케이스 4종 추가, 기존 `SubscriptionLineTest` 를 "renewal needed" 기대값으로 갱신.

### 핵심 설계 결정
- **Render-side filter, not server-side trim.** OpenClaw 카탈로그는 model-swap UI 등 다른 surface 가 그대로 사용해야 하므로 daemon/bridge 는 전체 카탈로그 유지. HUD 만 좁힘.
- **Past-date 는 daemon 에서 거르지 않고 UI 에서 라벨 변환.** Codex CLI 가 `subscription_active_until` 을 로그인 시 한 번만 쓰고 자동 갱신하지 않기 때문에 stale 은 정상 시나리오. cache 의 lossy fallback (`current ?? previous`) 도 그대로 둠 — re-login 으로 회복.
- **시간 의존 렌더링은 ticker state 가 default.** Compose 의 `produceState` 60s tick. SwiftUI 는 `EnvironmentObject` 변경 빈도가 충분해 별도 ticker 없음 (Codex stop-gate 도 Android 만 지적).

### 회고
같은 패턴 (`sub.until?.take(10)` raw render, `firstOrNull { it.available }` fallback) 이 4 파일에 흩어져 있었는데 stop-gate 가 3 라운드 도는 동안 발견. 첫 implementation 직전에 `rg -n "sub.until|firstOrNull { it.available }" android/app/src/main/kotlin/` sweep 한 번이면 모두 잡혔을 것. 사용자가 single-select 로 `default 만` 을 명시했음에도 "비면 어쩌지" 라는 추측으로 fallback 을 끼워 넣은 첫 라운드 regression 도 동일 류 — 사용자 명시 답에 hedge 추가 금지.

---

## 2026-05-08 — macOS App Store profile 호환성: App Group 제거

### 문제
- `apple-v1.0.4` CI에서 iOS archive/export/TestFlight 업로드는 성공했지만, macOS는 archive + `verify-appstore-archive.sh` 통과 후 `xcodebuild -exportArchive`에서 실패.
- 자동 signing export는 ASC API key cloud signing 권한 문제로 `Cloud signing permission error` / `No profiles`를 반환.
- 수동 export로 전환하자 현재 GitHub/local `AgentDeck Dashboard macOS AppStore` profile이 `com.apple.security.application-groups`를 포함하지 않아 entitlement/profile mismatch가 확인됨.

### 해결
- 현재 1.0 App Store 제품에는 helper/extension/login item이 없어 shared container가 필수 기능이 아니므로 `com.apple.security.application-groups` entitlement를 제거.
- App Store macOS 데이터 루트는 `AgentDeckPaths.swift`에서 앱 sandbox container의 `Application Support/AgentDeck`로 고정. Node CLI/unsigned dev/xctest는 기존 `~/.agentdeck/` 유지.
- Claude/Codex hook snippet, setup inlined snippet, Stream Deck plugin, bridge session registry는 discovery 순서를 `~/.agentdeck/daemon.json` → App Store sandbox container `daemon.json` → legacy App Group `daemon.json` → fallback으로 확장. 기존 pre-1.0 candidate와 호환성을 유지한다.
- `ExportOptions-macOS.plist`는 manual signing + `AgentDeck Dashboard macOS AppStore` provisioning profile + `Mac Installer Distribution` automatic selector로 명시해 CI가 cloud signing에 의존하지 않게 함.
- `CLAUDE.md`, App Review notes, feature matrix, certificate setup guide, README/daemon/TestFlight docs의 data-dir/App Group 설명을 현재 shipping contract에 맞게 갱신.
- `apple-v1.0.5` CI 결과: iOS 1.0.5 build 6 archive/export/TestFlight 업로드 성공. macOS archive + verifier 성공, export는 `No certificate ... matching 'Mac Installer Distribution'`로 실패. 현재 `APPLE_CERTIFICATE_BASE64` secret에는 Mac Installer private-key identity가 없으므로 secret 재수출이 필요하다.
- 다음 재시도는 이미 올라간 iOS build 6 중복 업로드를 피하기 위해 Apple version `1.0.6`, build `7`로 진행한다. workflow는 macOS job 초기에 `security find-identity -p basic`으로 `3rd Party Mac Developer Installer` identity를 검사해 누락 시 즉시 명확한 에러를 낸다.

---

## 2026-05-07 — Dashboard Timeline unit-session projection parity

### 문제

Dashboard Timeline 이 플랫폼별로 다른 단위를 보여줄 수 있었다. Apple Dashboard 는
`chat_start` 를 진행 중 row 로만 남기고 완료 후에는 `chat_response` / `chat_end`
중 의미 있는 완료 row 를 보여주는 projection 을 갖고 있었지만, Android tablet/e-ink
는 raw event 를 거의 그대로 그룹화했다. 특히 `tool_request` / `chat_end` 를 타입만
보고 묶어 서로 다른 세션이나 프로젝트의 이벤트가 같은 시간대에 합쳐질 수 있었다.

### 해결

- Android 공통 `TimelineDisplay.kt` projection 을 추가했다.
  - in-flight `chat_start` 는 같은 session/project completion 이 생기기 전까지만 표시.
  - `chat_response` 가 같은 turn 을 대표하면 중복 `chat_end` 는 숨김.
  - `runId → sessionId → projectName+agentType` 순으로 timeline context 를 매칭.
  - lifecycle bounds(start/end/duration)는 `startedAt`/`endedAt` 또는 paired
    `chat_start` 로 계산.
- Android `groupConsecutive` 는 이제 같은 context 일 때만 `tool_request` /
  `chat_end` 를 묶는다. Claude/Codex/OpenClaw/OpenCode 가 동시에 일해도 row 가
  섞이지 않는다.
- Android tablet `TimelineStrip` 과 e-ink `EinkEventLog` / `EinkTimelinePanel` 이
  같은 projection 을 사용한다.
- Android tablet row 에 `BrandIcon` 을 추가해 Claude/Codex/OpenClaw/OpenCode
  캐릭터/브랜드 자산이 project prefix 와 함께 보이게 했다.
- Apple Timeline detail pane 도 row 와 동일하게 `project · agent` 출처를 보여준다.

### 검증

- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.state.TimelineStoreTest --tests dev.agentdeck.state.TimelineDisplayScenarioTest --tests dev.agentdeck.net.ProtocolTest --no-daemon` 성공.
- `./gradlew :app:compileDebugKotlin --rerun-tasks --no-daemon` 성공. Kotlin daemon
  cache 오류 후 daemon-less fallback 으로 정상 컴파일됨.
- `./gradlew :app:testDebugUnitTest --rerun-tasks --no-daemon` 성공.
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk` 생성.
- `pnpm vitest run shared/src/__tests__/timeline.test.ts bridge/src/__tests__/apme-task-boundary.test.ts bridge/src/__tests__/apme-telemetry-envelope.test.ts` 성공 — 3 files / 93 tests.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataTimelineEval CODE_SIGNING_ALLOWED=NO` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_iOS -configuration Debug -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4),OS=18.6' -derivedDataPath /tmp/AgentDeckDerivedDataTimelineEvalIOS CODE_SIGNING_ALLOWED=NO` 성공.
- `git diff --check` 성공.

---

## 2026-05-07 — App Store 진단 UI/타임라인 안정성 정리

### App Store 진단 UI
- macOS Settings 포트 충돌 화면에서 `Copy Terminal Command` 버튼과 `lsof`/Terminal 안내 문구 제거.
- `PortDiagnostics`는 App Sandbox 밖 프로세스에 대해 PID/상태만 보여주고, 복구 경로는 `Clean Up & Retry`, 외부 앱에서 종료, 포트 변경으로 한정.
- 포트 blocker 표시명에서 `agentdeck daemon (CLI)` 같은 companion 실행 경로로 오해될 수 있는 문구를 `External AgentDeck process`/`External <agent> session`으로 변경.

### 타임라인 매칭
- Android/macOS timeline display grouping은 양쪽 엔트리에 non-empty `runId`가 있으면 `runId`가 같을 때만 같은 turn으로 간주.
- `runId`가 서로 다른 경우 같은 `sessionId`라도 in-flight start와 later completion을 병합하지 않아 병렬/연속 turn collapse를 방지.

### Apple release lane
- `.github/workflows/apple-release.yml`의 macOS App Store job을 실제 release dependency로 복구.
- macOS job은 `APPLE_CERTIFICATE_BASE64` combined p12(Apple Distribution + 3rd Party Mac Developer Installer identities)를 임시 keychain에 import하고, `MACOS_PROVISIONING_PROFILE_BASE64`가 비어 있으면 초기에 실패하도록 설정.
- Release 생성은 iOS + macOS upload가 모두 성공한 뒤에만 실행되도록 `needs: [build-ios, build-macos]`로 변경.
- macOS 전용 `Info-macOS.plist`를 분리해 iOS launch/orientation plist key가 Mac archive에 들어가지 않게 수정.
- `verify-appstore-archive.sh`가 macOS archive의 iOS-only plist key leak, invalid entitlement blob, app-sandbox entitlement 누락을 실패로 잡도록 강화.
- App Store metadata/review notes의 Codex/OpenCode 설명을 feature matrix와 맞춰 OpenCode는 App Store 밖 developer bridge로만 표기.
- iOS App Store archive는 `Apple Distribution` + `AgentDeck Dashboard AppStore` provisioning profile을 쓰는 manual signing으로 고정. verifier가 `get-task-allow=true` development archive를 실패로 잡도록 추가.
- local `scripts/build-apple-release.sh --ios` 경로도 archive 직후 `verify-appstore-archive.sh` 를 실행하도록 맞춰 CI와 동일한 게이트를 적용.
- macOS App Store archive도 `Apple Distribution` + `AgentDeck Dashboard macOS AppStore` provisioning profile manual signing으로 고정. `Packaging.log`에서 Apple Development로 app re-signing 되던 경로를 차단하기 위해 verifier가 development certificate signature를 실패로 잡도록 추가.
- GitHub Actions macOS job은 별도 `APPLE_MAC_INSTALLER_*` secret 대신 기존 `APPLE_CERTIFICATE_BASE64` combined p12(Apple Distribution + 3rd Party Mac Developer Installer identities)를 import하도록 정리. 현재 GitHub secret inventory에는 ASC + profile + combined certificate secrets가 존재하며 별도 Mac Installer secret은 없음.
- Local `bash scripts/build-apple-release.sh --all` 기준 Apple `1.0.4` build `5` iOS/macOS archive/export 및 `verify-appstore-archive.sh` 통과. 산출물은 `dist/export_ios/AgentDeck.ipa`, `dist/agentdeck-ios-v1.0.4.ipa`, `dist/export_macos/AgentDeck.pkg`.
- `apple-v1.0.3` CI macOS x86_64/arm64 Swift type-checking 실패(`AnthropicAdminApiClient.swift`)는 token 합산 expression을 작은 sub-expression으로 분리해 수정.
- 현재 로컬 shell에는 ASC env/API key가 없어 업로드는 skip됨. 최종 App Store Connect validation/TestFlight QA는 GitHub tag workflow 또는 로컬 ASC env 주입 후 수행해야 함.
- `apple-v1.0.2` 최초 CI run에서 `APPLE_CERTIFICATE_PASSWORD`가 빈 값인 passwordless p12 흐름과 `security import -t cert -f pkcs12` 조합이 signing identity를 만들지 못하는 문제가 확인됨. workflow는 빈 password 허용 + filter 없는 p12 import + `security find-identity -v -p codesigning` 진단 출력으로 수정.

### Bridge/package stability
- Codex APME turn-boundary 로직을 `CodexTurnManager`로 분리한 뒤 남아 있던 inline state-machine 참조를 제거하고, hook-primary + PTY fallback 경로를 manager로 라우팅하도록 정리.
- `classifyAndEnqueueTurn`을 `bridge/src/apme/classify-turn.ts`로 분리해 Codex manager와 index가 공유하고 circular import 없이 사용할 수 있게 함.
- Stream Deck plugin `rollup -c`가 번들 생성 후 macOS 로컬에서 event-loop handle을 남겨 root `pnpm build`를 멈추던 문제를 `plugin/scripts/build.mjs` Rollup API wrapper로 우회. `bundle.close()` 후 명시적으로 종료해 package build가 clean exit.
- 검증: `pnpm build`, `pnpm --filter @agentdeck/plugin typecheck`, `pnpm vitest run bridge/src/__tests__/apme-telemetry-envelope.test.ts bridge/src/__tests__/apme-collector.test.ts bridge/src/__tests__/state-machine.test.ts` 통과.

---

## 2026-05-06 — Codex timeline task unit alignment with APME turn

### 문제

Codex 세션의 timeline 이 한 user prompt 를 여러 chat 카드로 쪼개 표시했다.
원인은 `wireAgentApme` 가 `spinner_stop` 을 turn 종료 신호로 쓴 것 — Ink TUI 는
bash 가 실행되거나 상태 라인이 갱신될 때마다 spinner 를 멈춘다. 또한 Codex 의
도구 호출은 `tool_exec` 엔트리로 emit 되었으나 `timelineEntryToSpans` switch 의
default 로 떨어져 APME `tool_call` span 을 만들지 않았다 — eval 평가 단위와
사용자가 보는 timeline 단위가 단절돼 있었다.

### 해결

핵심 원리: 한 user prompt cycle = 한 `chat_start..chat_end` 쌍 = 한 APME turn.

**Parser** (`bridge/src/codex-output-parser.ts`):
- `idle` 이벤트에 `source: 'prompt' | 'timeout'` payload 부착. `prompt` 는
  `›\s` / 입력 prompt 가 실제로 보였다는 신호 (turn-end 후보), `timeout` 은
  spinner data 가 끊긴 합성 idle (대개 mid-tool 의 silence).
- `parseToolAction` 에 4초 dedup 추가 — Ink redraw 로 같은 명령이 여러
  chunk 에 매치돼 `tool_action` 이 폭발하던 문제 해소. turn 경계에서 reset.

**Bridge wiring** (`bridge/src/index.ts wireAgentApme`):
- spinner_start 에서 turn open, prompt-source idle 에서 turn close.
- 1.5s deferred close + spinner_start cancel 패턴 — 빠른 single-shot turn
  도 안 떨어뜨리고 status-line false-positive 도 막음.
- `codexInToolSilence` flag — timeout-idle 후 활성, spinner_start 시 해제.
  silence 동안 들어오는 prompt-idle 은 `codexPendingPromptIdle` 로 latch.
- `codexToolActiveSinceLastSpinner` — 이번 thinking segment 에 tool 이
  실제로 돌았는지 추적. timeout-idle 이 와도 이 flag 가 false 면
  end-of-thinking quiet 으로 보고 silence 진입 안 함.
- 15s `codexToolSilenceTimer` auto-clear — final response 가 spinner 재가동
  없이 끝나는 turn (예: tool 출력이 곧 응답) 에서 무한 차단 방지. 발화 시
  latched pending 을 즉시 close (deferred 아님 — 그새 next spinner_start 가
  cancel 할 수 있어).
- `codexPendingTailSnapshot` — latch 시점 PTY tail 을 snapshot. 후속 close
  (auto-clear timer / next spinner_start) 가 live ringbuffer 가 아닌 이
  snapshot 을 사용 → turn N+1 의 사용자 입력이 turn N 의 chat_response 로
  새지 않음.
- spinner_start with latched pending → 이전 turn 을 동기 close 후 새 turn
  시작 → "사용자가 다음 prompt 입력" 이 두 turn 의 merge 로 보이지 않게.

**APME adapter** (`bridge/src/apme/adapters/timeline.ts`):
- switch 에 `case 'tool_exec'` fallthrough 추가 → `tool_request` 와 동일하게
  `tool_call` span 생성. Codex 도구 호출이 `turns.tool_calls` 에 카운트됨.

### 핵심 설계 결정

- **`spinner_stop` 을 turn 신호로 쓰지 않는다**. Codex Ink TUI 는 bash/상태
  라인에 의해 spinner 가 자주 일시정지됨. 의미적으로 turn 종료가 아님.
- **`prompt`-source idle 만 turn-close 후보로 채택**. `timeout`-source idle
  은 mid-tool 의 spinner-silence 로 해석.
- **mid-tool stale idle 은 차단하되 영구 차단은 안 함**. 15s 안에 spinner
  재가동이 없으면 정말 turn 이 끝난 것으로 간주 (auto-clear).
- **사용자 다음 prompt 입력이 turn merge 를 일으키지 않게** spinner_start
  핸들러가 latched pending 을 동기 close 후 새 turn 개시.
- **PTY tail snapshot 은 latch 시점에 캡처** — close 가 늦게 일어나도
  contamination 없음.
- Trade-off: 단일 bash 가 15s 초과 + 그동안 stale `›` chunk 가 latch 를
  유발한 케이스에서 turn 이 split 될 수 있음. 일반 bash (<15s) 는 영향 없음.

### 회고: Codex stop-time review 의 가치

이 작업은 Codex review 가 한 회차에 하나씩 edge case 를 잡아 7회 iteration
했다 (idle guard → mid-tool close → status-line false positive → permanent
suppression → next-prompt drop → contamination → replay race). 각 회차마다
제안된 fix 가 다음 fix 의 race/edge 를 노출. 결과는 단일 PR 이지만
implementation 의 layered correctness (parser → state machine → snapshot →
race ordering) 는 review 없이는 도달하기 어려웠다. 같은 영역의 리뷰가
반복해서 다른 layer 의 bug 를 짚으면 — `feedback_codex_stoptime_iteration`
메모처럼 — "이미 고쳤음" 으로 무시 금지.

### 검증

- `pnpm -r build` 성공.
- `bridge` 패키지 `tsc --noEmit` clean.
- `npx vitest run -t "idle"` 48 passed; `-t "tool action"` 7 passed; `-t "spinner"` 36 passed; `-t "timelineEntryToSpans"` 7 passed.
- 추가된 테스트: codex-output-parser dedup 4 cases + idle source 단언 보강 + apme-telemetry-envelope `tool_exec → tool_call` 매핑 1 case.
- 수동 검증은 이번 세션에서 못 함 (Codex 실세션 필요): Apple/Android tablet timeline 에서 multi-bash prompt 가 chat 카드 1개로 표시되는지, `apme.sqlite` 의 Codex run turns.tool_calls > 0 인지.

---

## 2026-05-06 — Android e-ink readable Dashboard projection

### 문제

e-ink 전용 `EinkMonitorScreen` 이 tablet Dashboard 와 별도 화면 구조를 유지하는 것은 유지보수 위험이지만,
tablet `MonitorScreen` 을 그대로 e-ink 에 얹으면 더 큰 문제가 생긴다. 실제 Crema screenshot 에서
투명 HUD, 작은 timeline 글자, 색상 기반 상태, 장식 배경 위 텍스트가 겹쳐 판독성이 크게 떨어졌다.
따라서 공통화 대상은 픽셀 배치가 아니라 Dashboard state/action 계약이어야 한다. 동시에 Android 는
Swift daemon 이 보내는 fractional millisecond `timeline_event.ts` 를 `Long` 으로만 decode 해 timeline
이벤트를 버리고 있었다.

### 해결

- Android e-ink 는 다시 `EinkMonitorScreen` projection 을 사용한다. 이는 별도 제품 UX 가 아니라
  공통 Dashboard state/action 을 읽기 쉬운 고대비 3-zone 레이아웃으로 투영하는 계층이다.
- e-ink projection 의 좌측 session rail 을 28% 로 키우고, aquarium 을 42% 로 줄였으며,
  status/context band 를 20-30% 로 키워 글자와 touch target 을 확보했다.
- e-ink 핵심 텍스트를 키웠다: session label/subline, status gauges/models, timeline, attention panel 을
  13-16sp 중심으로 올리고 letter spacing 을 제거했다.
- Android Topology rail 은 Claude 관련 세션/model/rate-limit 데이터가 없고 OAuth 도 연결되지 않은 경우
  Claude row 를 숨긴다. Codex/OpenClaw 작업 중 `Claude Not connected` 가 세션 장애처럼 보이는 혼선을 줄였다.
- Android timeline parser 에 flexible timestamp serializer 를 추가해 `1711100000000.75` 같은 fractional
  timestamp 를 정상 수신한다.
- `docs/android-ui.md` 를 “shared Dashboard model + readable e-ink projection” 방향으로 갱신했다.

### 검증

- `./gradlew :app:compileDebugKotlin --no-daemon` 성공.
- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.net.ProtocolTest --no-daemon` 성공.
- `./gradlew :app:testDebugUnitTest --no-daemon` 성공.
- `bash scripts/build-android-release.sh` 성공, `dist/agentdeck-v0.4.1.apk` 생성.
- Crema(`CREMAA21W09235`)와 Lenovo tablet(`HVA095B4`)에 release APK 재설치 후 foreground 실행 확인.
- 새 screenshot 기준 Crema 는 readable e-ink projection, Lenovo 는 tablet Dashboard 를 사용하는 것을 확인.
- 양쪽 logcat 에서 `AndroidRuntime` crash 와 `parseBridgeMessage failed` / fractional timestamp decode
  실패가 더 이상 발생하지 않음을 확인.

---

## 2026-05-06 — Dashboard orientation parity for iOS/Android/e-ink

### 문제

Android tablet Dashboard 가 시스템 회전 잠금이나 저장된 orientation preference 상태에 따라
portrait/landscape 전환을 앱 안에서 회복하기 어려웠다. e-ink 는 별도 화면처럼 보이지 않도록
iOS/Android Dashboard 의 조작 의미를 유지해야 하지만, orientation 제어와 Settings 표시 여부가
표면별로 다른 규칙을 갖고 있었다.

### 해결

- Android 공통 `DashboardOrientation` 계약을 추가했다. e-ink 기본값은 landscape 고정, 일반 tablet 기본값은
  Auto 로 두고, 이전 `UNSPECIFIED` 저장값도 Auto 로 해석한다.
- Android tablet Dashboard 하단에 rotate control 을 추가했다. 이 버튼은 `Settings button` 표시 여부와
  독립적으로 남아 시스템 회전 잠금 상태에서도 portrait/landscape pinning 을 전환할 수 있다.
- Android tablet Settings 에 Orientation card 를 추가해 Auto / Portrait / Landscape 를 e-ink Settings 와
  같은 의미로 제공한다.
- e-ink landscape panel / portrait header / Settings 가 같은 orientation helper 를 사용하도록 정리했다.
- iOS Dashboard 의 rotate control 도 Settings button 표시 여부와 독립적으로 노출되게 했다.
- `docs/appstore-feature-matrix.md` 와 `docs/android-ui.md` 에 adaptive orientation / panel parity 계약을
  반영했다.

### 검증

- `./gradlew :app:compileDebugKotlin` 성공.
- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.data.DashboardOrientationTest` 성공.
- `./gradlew :app:testDebugUnitTest` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_iOS -configuration Debug -destination 'platform=iOS Simulator,name=iPad Pro 13-inch (M4),OS=18.6' -derivedDataPath /tmp/AgentDeckDerivedDataOrientation CODE_SIGNING_ALLOWED=NO` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataOrientationMac CODE_SIGNING_ALLOWED=NO` 성공.
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`.
- `git diff --check` 성공.

---

## 2026-05-06 — Stream Deck WS-close eviction (3-pass Codex review)

### 문제

macOS Dashboard 의 Downstream rail 에서 Stream Deck row 가 보이지 않는 케이스를
조사하다가, 더 깊은 stale-row 문제가 드러났다. SD 플러그인이 죽거나 Elgato 앱을
끄면 `cachedStreamDeck` 가 최대 120 s 동안 살아남았다. Codex stop-time review 가
세 번 연달아 같은 영역을 잡아냈다 — 한 번에 하나씩 더 깊은 race 가 드러났다.

### 해결 (3-pass)

1. **WS-close hook 비어있음** — `handleClientDisconnect()` 가 stub 였다. WebSocket
   `onCommand` / `onClientDisconnect` 콜백이 `WebSocketConnection` 식별자를 버리고
   있어서 daemon 쪽에서 어떤 conn 이 죽었는지도 알 수 없었다. conn 을 콜백에
   plumb 하고, `StreamDeckRegistration` 에 `connectionId: UUID` 를 박아 conn-id
   매칭 시 즉시 evict 하도록 했다.
2. **`receive()` 의 `isComplete` 무시** — peer 가 0x8 close frame 없이 그냥 TCP
   FIN 만 보내면 (kill -9, `ws.terminate()`, 프로세스 크래시, 머신 sleep)
   receive 콜백은 `(content: nil, isComplete: true, error: nil)` 로 무한 재귀하고
   `onClose` 가 발화되지 않았다. `isComplete` 분기를 추가하고, 마지막 데이터와
   FIN 이 같은 콜백에 합쳐 들어오는 경우를 대비해 frame 처리 후에 검사하도록 순서를
   조정했다.
3. **MainActor Task 사이 FIFO 보장 안 됨** — `client_register` 페이로드 + FIN 이
   같은 패킷에 들어오면, `onMessage` 가 TaskA(handleClientRegister) 를, `onClose`
   가 TaskB(handleClientDisconnect) 를 연달아 schedule 하지만 Swift Concurrency 는
   독립 Task 의 MainActor 도달 순서를 보장하지 않는다. TaskB 가 먼저 돌면 cache 에
   아무것도 없어 no-op 후, TaskA 가 죽은 conn 으로 cache 를 채워 다시 stuck.
   `WebSocketConnection.markDisconnected()` 를 receive 콜백 내부에서 동기적으로
   set 하고, `handleClientRegister` 가 시작 직후 `conn.isDisconnected` 면 거부하는
   패턴으로 어느 Task 가 먼저 돌든 안전하게 만들었다.

### 핵심 설계 결정

- 외부 콜백/I/O 가 actor-isolated 데이터로 reach 할 때 race 를 막는 두 패턴:
  (A) Task 사이 FIFO 가 필요하면 actor 진입 전에 동기적으로 lock-protected flag 를
  세팅, (B) 가능하면 actor state 를 직접 만지지 않고 Sendable snapshot 으로 분리.
  같은 세션의 SerialEventSnapshot 패턴이 (B) 의 예.
- Codex stop-time review 는 컴파일/단위 테스트로 못 잡는 동시성 race 를 잡는다는
  점을 이번에 확인했다. 첫 fix 후 review 가 또 다른 race 를 짚을 때 "이미 고쳤음"
  으로 무시하지 말 것. 메모리: `feedback_codex_stoptime_iteration.md`.

### 검증

- macOS xcodebuild ✓ (3 라운드 모두), iOS xcodebuild ✓, plugin vitest 135/135 ✓.
- 런타임 검증: SD 플러그인 정상 announce → row 표시 확인. (kill -9 / 머신 sleep
  시나리오는 사용자 환경에서 추후 회귀 모니터링.)

---

## 2026-05-06 — TIMELINE detail click crash hardening

### 문제

macOS Dashboard 에서 TIMELINE event 를 클릭해 detail pane 이 뜨기 직전에 `EXC_BREAKPOINT` 가 발생하는
재현 신호가 있었다. 직전 변경에서 detail text 를 `AttributedString(markdown:)` 기반 rich preview 로
바꿨는데, TIMELINE detail 은 agent 응답, tool JSON, table-like markdown, emoji, 로그 조각 같은 임의
문자열을 그대로 받는다. `try?` 는 parse error 만 fallback 할 뿐 Markdown parser 내부 assertion/trap 은
막지 못하므로 UI click path 에 시스템 Markdown parser 를 두는 것은 안전하지 않다.

### 해결

- `TimelineMarkdownPreview` 에서 `AttributedString(markdown:)` 호출을 제거했다.
- headings/list/numbered list/quote/fenced code 정도만 직접 분류하는 line-based safe renderer 로 교체했다.
- 별도로 Xcode 가 지목한 `DaemonServer.effectiveOauthConnected()` 경로는 ESP32 serial heartbeat callback 이
  `@MainActor` 서버 상태를 background actor 에서 읽을 수 있던 문제라서, serial-facing state/usage/display
  event 를 lock-protected snapshot 으로 전달하도록 분리했다.

### 검증

- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataTimelineMarkdownCrash CODE_SIGNING_ALLOWED=NO` 성공.
- 실행 중인 Dashboard 에서 TIMELINE row 를 여러 번 클릭해 detail pane 전환이 계속 동작하는 것을 확인했다.
- `git diff --check` 성공.

---

## 2026-05-06 — Voice assistant TCC callback crash fix

### 문제

macOS AgentDeck 이 daemon startup 이후 `CodexOTel`/D200H 로그를 마지막으로 조용히 종료되는 사례가
반복됐다. 진단 번들의 `.ips` crash report 는 `EXC_BREAKPOINT` / `_swift_task_checkIsolatedSwift` 를
가리켰고, faulting stack 은 `DaemonVoiceAssistant.start()` 에서 만든 speech authorization callback 이
TCC 백그라운드 큐에서 호출되는 경로였다. `DaemonVoiceAssistant` 가 `@MainActor` 이므로 Swift 6 런타임이
actor-isolated closure 를 잘못된 executor 에서 실행한다고 판단해 trap 한 것이다.

### 해결

- 마이크/음성 인식 권한 요청 callback literal 을 `@MainActor` 타입 밖의 `VoicePermissionRequester` 로
  이동했다.
- `SFSpeechRecognizer.recognitionTask` callback 도 non-actor `VoiceSpeechTranscriber` 로 분리했다.
- speech result continuation 은 `@unchecked Sendable` lock box 로 감싸 다중 callback resume 과 Swift 6
  concurrent-capture 오류를 함께 막았다.

### 검증

- `bash scripts/capture-apple-diagnostics.sh --tail 1500 --last 30m` 로 crash report 를 수집했다.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataVoiceCrash CODE_SIGNING_ALLOWED=NO` 성공.
- 수정 빌드 실행 후 `http://127.0.0.1:9120/health` 응답을 확인했고, startup 이후 OTel/D200H/hook 이벤트를
  지나 daemon 이 계속 살아있는 것을 확인했다.
- `git diff --check` 성공.

---

## 2026-05-06 — Dashboard TIMELINE detail markdown preview

### 문제

TIMELINE detail pane 이 assistant/eval detail 의 markdown 문법(`**bold**`, list, heading, inline code 등)을
원문 문자열로 그대로 보여 preview 로 읽기 어려웠다.

### 해결

- macOS Dashboard `TimelineStripView` 의 detail text 렌더링을 `AttributedString(markdown:)` 기반
  `TimelineMarkdownPreview` 로 교체했다.
- markdown parse 실패 시 원문 `AttributedString` 으로 fallback 한다. detail pane 의 선택/복사는 유지했다.

### 검증

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

---

## 2026-05-06 — Dashboard TIMELINE lifecycle 단위 정리

### 문제

macOS Dashboard TIMELINE 이 작업 단위가 아니라 저수준 이벤트를 그대로 노출했다. Claude Code 는 응답
캡처가 비면 사용자가 입력한 `chat_start` 만 남았고, `chat_end` 는 UI 에서 일괄 숨겨 완료 row 가 사라졌다.
Codex 는 APME/session state 로는 들어오지만 timeline entry 를 만들지 않아 Dashboard TIMELINE 에 보이지
않았다. OpenClaw tool row 는 tool 이름/입출력 요약이 충분히 복원되지 않아 어떤 tool 이 실행됐는지
판단하기 어려웠다.

### 해결

- 공통 `TimelineEntry` 에 `runId`/`startedAt`/`endedAt` 을 추가했다. Bridge/Android/Swift store 의 upsert
  경로도 이 lifecycle field 를 보존한다.
- macOS TIMELINE 은 완료된 turn 의 `chat_start` 를 목록에서 숨기고, `chat_response` 가 있는 경우
  메타 `chat_end` 를 숨긴다. 완료 row 의 detail pane 에 START/END/DUR 를 표시한다.
- macOS TIMELINE row 에 `SessionCreatureIcon` 을 넣어 Claude/Codex/OpenClaw/OpenCode source 를
  project prefix 와 별도로 식별하게 했다.
- Codex CLI/OTel/hook 경로에서 `chat_start`/`tool_exec`/`chat_response`/`chat_end` timeline entry 를
  생성한다. 응답 텍스트가 없으면 완료 row 는 남기고, 응답이 잡히면 result row 를 우선 표시한다.
- OpenClaw Gateway `session.tool` payload 는 `name/tool/toolName` 과 nested payload 를 더 폭넓게 읽고,
  input/output/error 를 JSON compact detail 로 보여준다.
- APME `eval_result` row 에 task/turn/run 의 시작/완료 시각을 실어 평가 결과가 실제 작업 lifecycle 과
  연결되게 했다.

### 검증

- `pnpm --filter @agentdeck/shared typecheck` 성공.
- `pnpm --filter @agentdeck/shared build` 성공.
- `pnpm --filter @agentdeck/bridge typecheck` 성공.
- `pnpm vitest run bridge/src/__tests__/timeline-integration.test.ts` 성공.
- `./gradlew :app:compileDebugKotlin` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataTimelineTask CODE_SIGNING_ALLOWED=NO` 성공.
- `pnpm vitest run bridge/src/__tests__/apme-task-boundary.test.ts ...` 는 현재 로컬 native optional dependency
  `better-sqlite3` 초기화 실패로 실행되지 않았다. Swift XCTest 는 test runner 가 app launch 상태에서
  장시간 반환하지 않아 중단했다.
- `git diff --check` 성공.

---

## 2026-05-05 — E-ink Attention parity 응답 패널

### 2026-05-06 추가 — 실기기 설치 검증

- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`.
- ADB 설치 완료: Pantone6 (`AA007422R24C1300039`, `lastUpdateTime=2026-05-06 03:51:37`),
  CremaS (`CREMAA21W09235`, `lastUpdateTime=2026-05-06 03:52:40`),
  Lenovo TB-J606F (`HVA095B4`, `lastUpdateTime=2026-05-06 03:51:41`). 모두
  `versionCode=5`, `versionName=0.4.1`.
- Pantone6/CremaS 는 재설치 후 `android.permission.WRITE_SECURE_SETTINGS` grant 를 다시 적용했다.
- 세 기기 모두 `dev.agentdeck/.MainActivity` foreground 확인. 최근 logcat 에서 `AndroidRuntime` fatal/crash
  로그 없음.

### 문제

Android tablet/iOS Dashboard 는 어떤 세션이든 `awaiting_*` 상태가 되면 Attention theater 를 띄워
해당 session 을 focus 한 뒤 `select_option` 응답을 보낼 수 있었다. 반면 e-ink Dashboard 는 현재 focused
primary state 가 awaiting 일 때만 `EinkContextArea` 에 옵션을 보여 다중 세션에서 e-ink 단독 사용자가
tablet/iOS 와 같은 응답 경험을 얻지 못했다.

### 해결

- e-ink 전용 `EinkAttentionPanel` 을 추가해 landscape/portrait context band 에 awaiting session 을
  표시한다.
- focused awaiting session 은 live question/options/cursor 를 보여주고, 다른 awaiting session 은 tap 시
  `focus_session` 후 `select_option` 을 보내 tablet/iOS 의 라우팅 의미와 맞췄다.
- parser 가 옵션을 못 준 경우 e-ink permission/attention UI 모두 `Yes`/`No`/`Always` fallback 을 보여
  blank approval panel 이 생기지 않게 했다.
- `docs/android-ui.md` 에 e-ink Attention parity 계약을 추가했다.

### 검증

- `git diff --check` 성공.
- `./gradlew :app:compileDebugKotlin` 성공.
- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.ui.eink.EinkAttentionPanelTest` 성공.
- `./gradlew :app:testDebugUnitTest` 성공.

---

## 2026-05-05 — Dashboard TIMELINE task summary 운영성 개선

### 문제

Dashboard TIMELINE 의 APME 평가 row 가 `★ task 85% [category]` 처럼 점수와 분류만 먼저 보여
운영자가 주변 시야에서 "어떤 작업이 끝났는지"를 바로 판단하기 어려웠다. 다중 세션 환경에서도 timeline
entry 에 project/session attribution 이 안정적으로 붙지 않아 AgentDeck/ViewTrans 같은 동시 세션을
TIMELINE 만 보고 구분하기 어려웠고, Android/e-ink 는 Claude Code 의 `chat_response` 뒤 `chat_end`
메타 row 를 그대로 보여 같은 턴이 중복 요약처럼 보일 수 있었다.

### 해결

- 공통 `TimelineEntry` 에 `projectName`/`sessionId` 를 추가하고, session timeline relay 와 bridge
  history/live broadcast 에서 누락된 attribution 을 보강했다.
- Node daemon 과 Swift in-process daemon 의 APME `eval_result` raw 를 `★ task 85% [debugging]
  <작업 요약>` 형태로 바꿨다. detail 에는 summary, axis score, done/missed, reasoning, project/prompt 를
  줄 단위로 넣어 compact row 와 detail pane 의 역할을 분리했다.
- Android tablet TIMELINE 과 e-ink TIMELINE/EventLog 가 project/source label 을 표시하고, Claude Code
  `chat_end` 메타 row 를 숨겨 iOS Dashboard 와 동일하게 한 턴이 한 줄 요약으로 읽히게 했다.
- timeline upsert 경로가 summary 만 교체할 때도 agent/project/session attribution 을 보존하도록
  Bridge/Android store 를 보강했다.

### 검증

- `pnpm --filter @agentdeck/shared typecheck` 성공.
- `pnpm --filter @agentdeck/shared build` 성공.
- `pnpm --filter @agentdeck/bridge typecheck` 성공.
- `pnpm vitest run bridge/src/__tests__/session-timeline-relay.test.ts bridge/src/__tests__/timeline-integration.test.ts` 성공.
- `./gradlew :app:compileDebugKotlin` 성공.
- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.state.TimelineStoreTest` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataTimelineTask CODE_SIGNING_ALLOWED=NO` 성공.
- `git diff --check` 성공.

## 2026-05-05 — E-ink Dashboard parity 구현 및 Pantone 배포

### 2026-05-05 추가 — e-ink portrait/landscape 양방향 보장

- `MainActivity` 의 e-ink orientation 적용을 `applyOrientationPreference()` 로 통합했다. landscape/portrait
  는 `requestedOrientation` 과 Pantone/RK3566 `USER_ROTATION` fallback 을 함께 적용하고, `Auto` 는
  `ACCELEROMETER_ROTATION=1` 로 system auto-rotation 을 복원한다.
- e-ink rotate control 을 `Settings button` 표시 여부와 분리했다. Display panels 에서 settings button 을
  숨겨도 portrait/landscape 전환은 계속 가능하다.
- Pantone6 (`AA007422R24C1300039`) 실기기에서 portrait screenshot → rotate tap → landscape screenshot
  → rotate tap → portrait screenshot 순서로 검증했다. 설치된 APK 는 `versionName=0.4.1`,
  `lastUpdateTime=2026-05-05 14:50:21`; 최근 logcat 250라인에서 `AndroidRuntime`/`FATAL EXCEPTION`
  매치 없음.

### 문제

Dashboard UX parity 정렬 후에도 e-ink 화면은 새 방향성에 비해 덜 따라와 있었다. tablet/iOS 와
동일해야 하는 session focus, settings 구조, display panel 토글, downstream device 관계가 e-ink 에서는
부분적으로만 반영됐고, `Tank status`/`Device diagnostic` 토글도 실제 compact status 표시에 분리 적용되지
않았다.

### 해결

- e-ink landscape/portrait 모두 `Display panels` 선호도를 읽어 session list, tank status, device
  diagnostic, timeline, settings button 표시를 제어하도록 연결했다.
- e-ink session list row tap 이 tablet 과 동일하게 `focus_session` bridge command 를 보내도록 했다.
- e-ink settings overlay 를 Connection / Mac integrations(read-only) / Display panels / Display &
  sleep / Orientation 구조로 확장해 tablet/iOS 설정 의미와 맞췄다.
- `EinkStatusCompact` 에 downstream device summary 를 추가하고 `Tank status` 와 `Device diagnostic`
  토글을 각각 반영했다. `moduleHealth` 기반으로 Stream Deck, D200H, Pixoo, ESP32, Android/e-ink
  관계를 압축 표시한다.
- 공통 Connection panel 의 bridge URL 표시에서 `token=` 값을 redaction 하도록 해 Settings 에서 pairing
  token 이 그대로 노출되지 않게 했다.
- e-ink 전용 EPD refresh zone, color/grayscale renderer, 작은 화면 layout 제약은 그대로 보존했다.

### 검증

- `git diff --check` 성공.
- `./gradlew :app:compileDebugKotlin` 성공.
- `./gradlew :app:testDebugUnitTest` 성공.
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`.
- ADB 설치 완료: Pantone6 (`AA007422R24C1300039`, `versionName=0.4.1`,
  `lastUpdateTime=2026-05-05 13:53:22`), Lenovo TB-J606F (`HVA095B4`,
  `lastUpdateTime=2026-05-05 13:56:16`).
- Pantone6 에서 `dev.agentdeck/.MainActivity` resumed 확인. 최근 logcat 250라인에서
  `AndroidRuntime`/`FATAL EXCEPTION` 매치 없음.
- Lenovo TB-J606F 에서도 `dev.agentdeck/.MainActivity` resumed 확인. 최근 logcat 은 정상 launch/draw
  로그만 확인되고 fatal crash 없음.
- Pantone6 screenshot 으로 Dashboard 의 `Devices: D200H, Pixoo` compact downstream 표시와 Settings
  overlay 의 Connection / Mac integrations / Display panels 렌더링, `token=redacted` 표시를 확인했다.

---

## 2026-05-05 — Android tablet OpenClaw foreground 배치 보정

### 문제

Android tablet Monitor 화면에서 OpenClaw 메인 가재가 하단 `TIMELINE` detail pane 과 겹칠 때,
Compose 레이어 순서상 terrarium canvas 전체가 먼저 그리고 `TimelineStrip` 이 나중에 올라와 가재가
텍스트 뒤에 가려졌다. 기존 canvas 내부 draw order 를 조정해도 Compose sibling 인 TIMELINE 보다
앞으로 나올 수 없는 구조였다.

### 해결

- Tablet Monitor 전용 OpenClaw 기준점을 기존 우하단 모래선 근처에서 위/왼쪽
  (`x=0.70`, `y=0.575`) 으로 옮겨 TIMELINE detail pane 침범을 줄이면서 오른쪽 HUD rail 을 피했다.
- 활성 OpenClaw 상태에서는 메인 가재를 배경 canvas 에서 빼고 `TimelineStrip` 뒤가 아닌 별도
  foreground canvas 에서 그리도록 분리했다. 레이어 순서는 `TIMELINE < OpenClaw < HUD/gear` 로
  유지해 OpenClaw 는 TIMELINE text 보다 앞에 나오되, topology rail 같은 HUD 텍스트는 가리지 않는다.
- Worker 가재 배치는 조정된 메인 가재 기준점을 받을 수 있게 하여 tablet 위치 변화와 같이 움직인다.

### 검증

- `./gradlew :app:compileDebugKotlin` 성공.
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`.
- ADB 설치 완료: Lenovo TB-J606F (`HVA095B4`, `lastUpdateTime=2026-05-05 13:34:25`),
  Pantone6 (`AA007422R24C1300039`, `lastUpdateTime=2026-05-05 13:36:44`).
- Lenovo TB-J606F 실기기 screenshot 으로 레이어 순서 확인.

---

## 2026-05-05 — Dashboard UX parity: macOS/iOS/Android tablet 정렬

### 문제

macOS Dashboard 는 host-side 기능이 추가되어 있고 Android tablet 은 별도 Compose 설정/명령 경로를
쓰면서 iOS Dashboard 와 세부 경험이 갈라졌다. 특히 Android 는 세션 리스트/Attention 카드에서
focus session 을 먼저 보내지 않아 다중 awaiting 세션 응답이 iOS 와 다르게 라우팅될 수 있었고,
Display panels 토글 및 downstream device topology 가 iOS/macOS 와 맞지 않았다.

### 해결

- macOS Dashboard 의 특수 창/menubar 기능은 유지하면서, 빈 수족관 배경 탭으로 HUD 를 숨기는
  aquarium viewing interaction 을 iOS 와 동일하게 적용했다.
- Android tablet 에 `focus_session` command 를 추가하고 세션 리스트 row, Attention 카드 focus/respond
  경로에 연결했다.
- Android tablet settings 에 iOS 와 같은 `Mac integrations` read-only card 와 `Display panels` 토글을
  추가하고, 세션 리스트/토폴로지/타임라인/설정 버튼 표시가 해당 선호도를 따르도록 했다.
- Android protocol/state/topology rail 이 `moduleHealth` 를 수신해 Stream Deck, D200H, Pixoo, ESP32,
  Android/e-ink devices 를 downstream 관계로 표시하도록 맞췄다. 데이터가 없을 때만 `This tablet`
  fallback 을 유지한다.

### 검증

- `./gradlew :app:compileDebugKotlin` 성공.
- `./gradlew :app:testDebugUnitTest` 성공.
- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination platform=macOS build` 성공.
- `git diff --check` 성공.

---

## 2026-05-05 — Android OpenClaw false-active 표시 보정

### 문제

Android 태블릿/e-book 대시보드에서 OpenClaw Gateway 가 인증되지 않았거나 끊긴 상태인데도
이전 worker 가재, OpenClaw 모델 라인, worker count 가 남아 OpenClaw 가 활성화된 것처럼 보였다.
원인은 Android `AgentStateHolder` 가 `modelCatalog`/`workerSessionCount` 를 null-coalescing 으로
보존하고, 일부 e-ink/portrait HUD 표면이 `gatewayConnected` 대신 stale catalog/count 를 직접
표시한 데 있었다. Node daemon 쪽도 Gateway disconnect 때 `sessions_list` 만 먼저 내보내고
`gatewayConnected=false` state update 가 늦게 도착할 수 있었다.

### 해결

- Android state merge 에서 `gatewayConnected != true` 이면 OpenClaw capabilities/sessionStatus/worker
  count 를 clear 하고, Gateway unavailable 상태의 stale error 를 false 로 수렴시켰다.
- Terrarium worker crayfish 는 인증 완료(`gatewayConnected=true`)일 때만 렌더 count 를 넘긴다.
- e-book compact/status/portrait header 와 tablet HUD worker count 를 `gatewayConnected` 기준으로
  게이트했다. tablet topology rail 은 reachable-but-unauthenticated 상태를 `Not connected` 로 표기한다.
- Node daemon 은 Gateway disconnect 이벤트에서 즉시 `state_changed` 를 emit 하고, Gateway unavailable
  probe 시 stale `gatewayHasError` 도 clear 한다.

### 검증

- `git diff --check` 성공.
- `./gradlew :app:testDebugUnitTest --tests dev.agentdeck.terrarium.TerrariumStateTest` 성공.
- `./gradlew :app:testDebugUnitTest` 성공.
- `pnpm --filter @agentdeck/bridge typecheck` 성공.
- `bash scripts/build-android-release.sh` 성공 → `dist/agentdeck-v0.4.1.apk`.
- ADB 설치 완료: Pantone6 (`AA007422R24C1300039`), Lenovo TB-J606F (`HVA095B4`), 둘 다
  `versionCode=5`, `versionName=0.4.1`, `lastUpdateTime=2026-05-05 12:42` 확인.

---

## 2026-05-05 — OFFLINE 히어로 SD+ 2×2 클러스터 + D200H teardown 프레임

### 문제
- `d030c142` 의 단일-키 OFFLINE 히어로가 짝수×짝수 데크(SD+ 4×2, SD XL 8×4) 에선 진짜 중앙이 없어 `floor(rows/2)*cols + floor(cols/2)` 가 항상 우하단으로 치우친 후보를 골라 시각 균형이 깨짐. SD+ 에서 슬롯 6 (button 7) 에 표시돼 어색.
- D200H 는 데몬이 종료되면 마지막 세션 화면이 그대로 남아 OFFLINE 표시가 전혀 없음. App 종료 후 재실행 전까지 stale 한 정보가 키패드에 떠 있음.

### 해결
- 공용 렌더러 `renderOpenAppQuadrant(quadrant)`: 288×288 논리 캔버스를 4 개의 144×144 viewBox 로 클리핑(transform translate). 각 키는 자기 외곽 둥근 모서리를 유지하면서 panel/icon/text 가 4 키에 걸쳐 하나의 카드로 보임.
- `computeCenterCluster(layout)`: 짝수×짝수 → tl/tr/bl/br 사분면 4 슬롯, 그 외 → 단일 'full' 슬롯 폴백. 기존 `computeCenterSlot` 시그니처는 보존.
- `session-slot-button.ts`: hero/keypress 둘 다 cluster 기반 — 클러스터 4 키 어디 눌러도 `openAgentDeckAppOrGitHub()` 발동.
- D200H Swift `sendOfflineFrame()`: `stop()` 에서 `disconnect()` 직전 호출, 14 ButtonSlot dim + 슬롯 7 (col=2,row=1, 시각 중앙) 에 OFFLINE/Open AgentDeck 카드 push. App→DaemonService.stop→DaemonServer.shutdown→ModuleManager.stopAll→D200hHidModule.stop 경로로 도달.

### 핵심 설계 결정

**1. 짝수 그리드 정중앙은 4-키 분산이 정답**
floor 공식이 항상 한쪽으로 치우치는 한계는 산술적으로 풀 수 없음. SVG viewBox 클리핑으로 한 카드를 4 등분하면 진짜 시각 중앙이 가능하고, 카드 자체도 2 배 크게 그릴 수 있어 OFFLINE 같은 hero 신호로는 단일 키보다 강함. 홀수 차원은 단일 hero 유지 (true center 가 존재).

**2. ButtonSlot.textOverlay 기본값 .none = 텍스트 미렌더 함정**
`renderButtonPng` 의 텍스트 분기는 `slot.textOverlay` 가 `.none` 이면 title/subtitle 둘 다 무시하고 아이콘만 그림 (line 2544). 직관과 반대 — title 이 비어있지 않으면 그릴 거라 가정하면 망함. status tile 류는 모두 `.infoTile` 명시 필요. Codex stop-time review 가 잡음.

**3. App 강제종료/크래시는 비범위**
graceful teardown (Cmd-Q, SIGTERM 등) 에선 `stop()` 이 호출되므로 OFFLINE 프레임 push 가능. SIGKILL/패닉은 별도 보조 프로세스(LaunchAgent 등) 없인 캐치 불가 — 큰 작업이라 별 이슈로 분리.

---

## 2026-05-05 — OpenClaw Settings 정상 상태 UI 정리

### 문제

OpenClaw Gateway 연결이 정상화된 뒤에도 Settings → Integrations 의 OpenClaw row 가
`Connected / Paired through Gateway` 아래에 token import, reconnect, Web UI, reset identity 버튼과
4개 bullet 설명을 항상 노출했다. 정상 상태에서는 "연결됨" 확인만 필요하고, 이 수리 도구들은 오히려
불필요한 조작처럼 보였다. Accounts 섹션의 "No tokens to paste here" 문구도 OpenClaw Advanced token
field 와 충돌했다.

### 해결

- OpenClaw troubleshoot row 는 Gateway 가 available 이면서 connected 가 아니거나 auth error 상태일
  때만 inline 으로 표시한다.
- 정상 connected 상태에서는 `Advanced` disclosure 만 남기고, token refresh / reconnect / Web UI /
  reset identity / token paste field 는 그 안으로 이동했다.
- 기존 4개 bullet 설명을 상태별 1줄 hint 로 축소하고, 버튼 라벨도 `Import token`, `Open Web UI`,
  `Reset identity` 로 줄였다.
- Accounts 섹션 설명을 "repair tools stay in Advanced" 로 바꿔 optional token repair 경로와 충돌하지
  않게 했다.

### 검증

- 정상 연결 상태 UI: OpenClaw row 는 `Connected / Paired through Gateway` + 접힌 `Advanced` 만 표시.
- `Advanced` 확장 시 repair controls 와 token field 가 노출됨.
- `git diff --check` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataOpenClawSettingsTrim CODE_SIGNING_ALLOWED=NO` 성공.

---

## 2026-05-05 — OpenClaw token-only fallback 무응답 타임아웃 + Settings reconnect

### 문제

Xcode-run App Store-gated macOS 앱에서 OpenClaw Gateway TCP/health 는 정상이고
Settings → Integrations 에서 `openclaw.json` 토큰도 Keychain 에 저장되어 있었지만, Dashboard 는
`gatewayAvailable=true`, `gatewayConnected=false`, `gatewayAuthStatus=gateway_reachable` 로
"Awaiting setup / Connecting to Gateway" 에 머물렀다. Swift daemon 로그상 첫 `connect` 는
`DEVICE_AUTH_SIGNATURE_INVALID` 로 거부되고, 그 뒤 token-only fallback 이
`fallback=true hasDevice=false hasSharedToken=true hasDeviceToken=false` 로 전송되지만 Gateway 응답이
돌아오지 않았다. 기존 Swift 어댑터는 fire-and-forget `connect` RPC 에 timeout 이 없어 이 상태를
명시 실패나 재시도로 전환하지 못했다.

### 해결

- `OpenClawAdapter` 의 pending RPC 에 timeout 을 추가했다. 특히 `connect` RPC 무응답은 socket 을
  닫아 기존 reconnect 루프로 복귀시키고, 첫 device-auth connect 가 조용히 drop 되면 token-only
  fallback 으로 한 번 전환한다.
- token-only fallback connect 까지 timeout 되면 `connect_timeout` auth status 를 내보내 UI 가
  무한 "Connecting" 으로 남지 않게 했다.
- `ADGatewayFrame` 디코드가 큰 `hello-ok` payload 에서 실패해도 raw JSON envelope 의
  `type=res/event` 로 response/event 를 처리하게 했다. Gateway 가 실제로 `ok=true` 를 보냈는데
  Swift 가 payload union 디코드 실패 때문에 응답을 drop 하는 경로를 막는다.
- Settings → OpenClaw troubleshoot row 에 명시적 **Reconnect adapter** 버튼을 추가했다. 토큰 입력칸
  Save 버튼이 빈 입력 때문에 비활성화된 상황에서도 daemon 전체 restart 없이 OpenClaw adapter 만
  bounce 할 수 있다.

### 검증

- Xcode-run diagnostics: Gateway `available=true`, `connected=false`, `authStatus=gateway_reachable`,
  Swift log 에서 token-only fallback 무응답 확인.
- OpenClaw `gateway.err.log`: AgentDeck 첫 시도는 `code=1008 reason=device signature invalid` 로
  닫힘.
- `git diff --check` 성공.
- `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataOpenClawConnectTimeout CODE_SIGNING_ALLOWED=NO` 성공.
- Xcode 종료 후 새 Debug `.app` 직접 실행 검증: 수정 전에는 Dashboard/Settings 가 `Handshake
  timeout` / `connect_timeout` 을 표시하고, Settings 의 **Reconnect adapter** 클릭 시 adapter-only
  reconnect → token-only fallback → 10s timeout 으로 다시 수렴했다.
- raw-envelope fallback 수정 후 새 Debug `.app` 재실행 검증: `/status.modules.gateway` =
  `{available:true, connected:true, authStatus:"connected"}`, `sessions=1`. Swift log 에
  `connect ok=true`, `sessions.subscribe ok=true`, `health ok=true`, `sessions.list ok=true`,
  active session `agent:main:main` 확인.

---

## 2026-05-05 — macOS Quit 후 BridgeConnection 재접속 루프 차단

### 문제

메뉴바 Quit 또는 Cmd+Q 종료 중 in-process daemon 은 `Daemon stopped` 까지 정상 shutdown 되지만,
dashboard 쪽 `BridgeConnection` / stale-data watchdog / mDNS discovery 가 종료 상태를 모르고
`ws://127.0.0.1:9120` 재접속을 계속 예약했다. 그 결과 daemon 종료 직후 `Connection refused`
로그가 반복되고 앱이 살아 있는 것처럼 보였다.

### 해결

- `AgentStateHolder.prepareForTermination()` 추가. Quit 시 preferred local bridge, auto-connect
  timer, stale monitor, wake listener, discovery, bridge connection 을 먼저 종료 모드로 전환한다.
- `BridgeConnection.prepareForTermination()` 추가. WebSocket, URLSession, ping timer, reconnect
  work item 을 취소하고 이후 `connectInternal` / receive callback / ping / reconnect 가 재진입하지
  않도록 termination guard 를 둔다.
- `BridgeDiscovery.prepareForTermination()` 추가. NWBrowser restart 예약과 late resolve/health
  callback 이 종료 중 bridge list 를 다시 채우지 못하게 guard 한다.
- 메뉴바 Quit 은 daemon 을 직접 stop 하지 않고 `NSApplication.terminate` 로 단일 종료 경로에
  위임한다. AppDelegate 는 daemon shutdown 전에 dashboard-side reconnect loop 를 먼저 끈다.

### 검증

- `xcodebuild -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination platform=macOS -derivedDataPath /tmp/AgentDeckDerivedDataQuitFix build CODE_SIGNING_ALLOWED=NO`
  성공.

---

## 2026-05-04 — Xcode-run Apple diagnostics workflow for coding agents

### 문제

앱은 Xcode 에서 직접 실행/재현하고 코드 수정은 Claude Code/Codex 에이전트가 맡는 흐름에서,
Xcode console / OSLog / Swift daemon file log / `/diag` 상태를 매번 사람이 복사해 전달해야 했다.
그 결과 로그 누락, 포트 착각, in-process Swift daemon vs external Node daemon 혼동, hang sample 누락이
반복됐다.

### 해결

- `scripts/capture-apple-diagnostics.sh` 추가. repo-local 개발 도구로 daemon port 를
  `daemon.json` 또는 9120-9139 scan 으로 찾고, `/status`, `/diag`, `/devices`, `/usage`,
  Swift daemon log tail, AgentDeck OSLog, process list, 짧은 `sample` 을
  `diagnostics/apple-xcode/<timestamp>/` 에 수집한다.
- `.agents/workflows/apple-xcode-debug.md` 추가. Xcode 에서 재현된 Apple 앱 이슈는 사용자에게
  콘솔 복붙을 요구하기 전에 이 workflow 로 bundle 을 만들고 `diagnostics/apple-xcode/latest/`
  를 읽도록 표준화했다.
- `CLAUDE.md` 의 Development 섹션에 Apple/Xcode Debug Diagnostics 지침을 추가했다. 이 경로는
  App Store 앱 내부 기능이 아니라 저장소 측 개발 도구이며, App Store UI 나 Swift app source 에
  subprocess/terminal 안내를 추가하지 않는다는 경계를 명시했다.
- `.gitignore` 에 `diagnostics/` 를 추가해 수집 산출물이 커밋되지 않게 했다.

### 검증

- 앱 소스와 Xcode project 파일은 변경하지 않음.
- 수집 스크립트는 credential-bearing 파일(`auth-token`, `settings.json`, OpenClaw config)을
  의도적으로 제외한다.

---

## 2026-05-03 — OpenClaw 페어링 stuck 진단 + UX/Review-notes 동기화 (16-step)

### 문제

App Store / Group Container 빌드에서 사용자가 Settings → OpenClaw 에 토큰을 저장했음에도
"Awaiting setup" 으로 영구히 머무는 상태. 16 단계에 걸쳐 누적 진단 + fix.

핵심 단서:
- `/status.gateway` = `{available:true, connected:false, authStatus:"gateway_reachable"}`
- 처음엔 silent drop 으로 오인 → 실제로는 OpenClaw 측 ws close 1008 reason 으로 거부 (74건
  누적, 8가지 reason: `device signature invalid`, `device identity required`, `gateway token
  missing`, `unauthorized: device token mismatch`, `invalid handshake`, `invalid connect
  params`, `connect failed`, `unauthorized: too many failed authentication attempts`).
- 사용자 Web UI 도 unauthorized 라며 token 입력 요구 → SPA bundle 분석 결과 OpenClaw
  control-ui 자체에는 token UI 가 없고 (`Chat settings` / `Search settings` 만), token 은
  `~/.openclaw/openclaw.json` 의 `auth.token` (plaintext) 으로만 관리.

### 해결 (16-step 정리)

1. **shared-token 분기 제거** (`OpenClawAdapter.swift sendConnectRequest`) — 옛 `shouldSendDeviceAuth = hasDeviceToken || !hasSharedToken` 분기가 dmPolicy=pairing 환경의 첫
   페어링을 막고 있었음.
2. **RPC error 기반 fallback** — `device_auth_invalid` 응답 + flag 미설정 → flag set + 즉시 retry.
3. **Fallback 시 device 자격증명 전체 차단** — `params["device"]`/`auth["deviceToken"]`/`scopes`
   모두 제거. 진짜 token-only 보장.
4. **close-reason aware fallback** — RPC 응답 안 오는 transport-level 1008 close 도
   `receiveLoop` 가 `task.closeCode/closeReason` 추출 → `handleDisconnect` 가 reason 별 분기.
5. **`expectingClientInitiatedClose` flag** — fallback 의 `wsTask.cancel` 이 receiveLoop 의 close
   event 를 트리거 → 그 close 의 server reason 이 다시 fallback exhausted 분기로 들어가
   `pairingRequired=true` 차단 = 자기-차단. flag 로 직후 1회 close 만 reason 분기 skip.
6. **narrow restart** — `daemonService.restart()` (daemon 전체 재시작 → Claude Code/Codex
   sessions 끊김) 대신 `reconnectGatewayAdapter()` (OpenClaw adapter 만 bounce). DaemonServer
   에 public `reconnectGatewayAdapter()` 추가, DaemonService 에 forward.
7. **NSOpenPanel token import** — Settings → OpenClaw troubleshoot row 에 "Import token" 버튼.
   `NSOpenPanel` user-selected scope (`com.apple.security.files.user-selected.read-write`,
   `startAccessingSecurityScopedResource()` + `defer stop`) → `Data(contentsOf:)` →
   `JSONSerialization` → `auth.token` 만 사용 → Keychain 저장 → adapter reconnect.
8. **`getpwuid(getuid()).pw_dir`** — `panel.directoryURL` 을 사용자 real home 으로 set.
   `NSHomeDirectory()` / `$HOME` env 는 sandbox 안에서 container path 반환 → real home 못
   가리킴. `getpwuid` 패턴 (memory `swift-daemon-server.md`) 사용. NSOpenPanel 은 Powerbox
   에서 동작하므로 sandbox-external path 를 navigation hint 로 받아도 권한 위반 아님.
9. **`OpenClawDeviceIdentityStore`** — Keychain self-gen identity 삭제 정적 메서드. Settings 의
   "Reset pairing identity" 버튼이 호출 → 다음 connect 에 fresh Ed25519 키쌍 생성.
10. **App Store reachable 카피 정정** — "Start OpenClaw" 같은 launch-instruction 제거 ("When
    OpenClaw is running, ..." conditional 형태). App Review 4.2.3 sensitivity.
11. **사용자 카피 vs review notes 톤 분리** — 같은 행위를 reviewer 정확성 톤 (overstatement
    제거: "uses only X" / "saves only X" / "persists only X") 과 사용자 친근 톤 (path hint
    포함) 으로 분리 작성. 사용자가 옵션 1 (path hint 포함) 선택.
12. **`APP_REVIEW_NOTES.md` 동기화** (4 라운드) — line 70 + line 75 cross-reference 일관성:
    `directoryURL` set 동작, `~/.openclaw/` enumerate 안 함, "hardcode" 의 두 의미 (string
    literal in copy vs runtime file-system target) 분리. line 70 의 "never sets directoryURL"
    → "never points it *at* `~/.openclaw/`" 로 좁힘.
13. **`.help(_:)` markdown warning 해소** — SwiftUI `.help(_:)` 의 default `LocalizedStringKey`
    overload 가 backtick/em-dash/quote 를 styled run 으로 파싱 → "Only unstyled text" warning.
    4 곳 (line 602, 1072, 1083, 1092) 모두 `.help(Text(verbatim:))` overload 로 wrap.
14. (그 외 단계 8/13/14 의 false claim 정정 — "(sandbox-internal)" 잘못된 claim 제거 등 누적)
15. **`docs/appstore-feature-matrix.md` 갱신** — CLAUDE.md invariant ("Feature matrix is canonical.
    New features land in the table before any implementation touches the App Store target") 준수.
    OpenClaw 행에 4 개 새 row 추가: shared-token Keychain 저장, shared-token import from JSON
    config (NSOpenPanel + user-selected scope + getpwuid real home), device pairing identity
    reset (`reconnectGatewayAdapter` only, sessions 영향 없음), Web UI deep link
    (`NSWorkspace.open`). 각 행에 entitlement + blast radius 명시.

### 핵심 설계 결정

| 결정 | 이유 |
|---|---|
| RPC fallback + transport (close-reason) fallback **둘 다 유지** | OpenClaw 가 모드/버전에 따라 RPC error vs ws close 둘 다 사용 |
| `disableDeviceAuthForNextConnect` 는 sharedToken 유무 무관 트리거 | Xcode Debug vs App Store keychain access group 차이로 token 못 읽는 환경에서도 fallback 동작 |
| `directoryURL = real home` (sandbox-external) | NSOpenPanel 은 Powerbox 라 권한 위반 아님; container path 는 OpenClaw 가 없는 곳이라 무의미 |
| 사용자 facing path hint vs runtime hardcode 분리 | App Review invariant 는 runtime file-system target 만 의심, copy 의 string literal 은 무관 |
| Reviewer 정확성 톤은 review notes 만, 사용자 카피는 친근 톤 | 같은 행위라도 audience 별 wording 분리. "reads only X" vs "uses only X" 처럼 동사 정확성 |

### 미해결 (사용자 측 영역)

우리 16 단계 fix 후에도 OpenClaw v2026.4.14 가 우리 v3 Ed25519 서명을 `DEVICE_AUTH_SIGNATURE_INVALID`
로 거부 + Web UI 가 `device token mismatch (rotate/reissue device token)` 안내. 진짜 root cause
는 OpenClaw 측 v3 verify 코드 또는 token state stale. 사용자 (OpenClaw 운영자) 가 직접 점검:

1. `openclaw update` (v2026.4.14 → v2026.4.26) — gateway.log 에 update available 알림.
2. `~/.openclaw/openclaw.json` 의 `auth.token` 새 값 발급 → Gateway 재시작 → AgentDeck Import
   token 다시.
3. `device-pair` 플러그인의 v3 verify 함수가 `docs/gateway-protocol.md` line 55-71 의 spec
   대로 reconstruct 하는지 직접 확인.

---

## 2026-05-01 — Codex session lifecycle canonicalization

### 문제

이전 보완은 Terrarium/Pixoo 렌더 계층에서 Codex companion thread 를 접고, terminal event 이후 TTL 을 줄이는 방식이었다. 하지만 여전히 근본 경계가 남아 있었다.

- `sessions_list` 자체는 raw Codex thread 를 그대로 내보내서 `/status`, D200H, Stream Deck, SessionListPanel 이 서로 다른 세션 수를 볼 수 있었다.
- `codex_stop`/OTel `turnEnd` 이후 늦게 도착한 `codex_tool_start`/`codex_tool_end`/stream span 이 `lastTerminalCodexEventBySession` 을 지우고 `processing` 으로 되살릴 수 있었다.
- 현재 Codex OTel span 이름(`turn/start`, `responses_websocket.stream_request`, `exec_command`, `dispatch_tool_call_with_code_mode_result`) 상당수가 parser 에서 unknown 으로 떨어졌다.
- OTel `session_id` fallback 도 hook 쪽과 달리 짧은 숫자 id 를 durable thread 로 승격할 여지가 있었다.

### 해결

- `DashboardDataRules.foldCodexSessionPayloadsForDisplay()` 를 추가하고 `DaemonServer.buildSessionsListEvent()` 에서 `sessions_list` 생성 직전에 적용했다. 이제 표시 표면은 모두 같은 folded Codex count 를 받는다. 동일 `projectName` 의 Codex rows 는 상태 우선순위(`processing` > `awaiting_*` > `idle`)와 최신 시작 시간을 기준으로 대표 row 하나로 합쳐지고, `groupSize`/`foldedSessionIds` 를 싣는다. 빈 projectName 은 접지 않는다.
- Stream Deck plugin 도 `foldCodexSessionsForDisplay()` 공유 유틸을 사용해 구버전/외부 daemon 이 raw rows 를 보내도 버튼 수가 부풀지 않게 했다.
- terminal stamp 는 새 턴 신호(`codex_session_start`, `codex_user_prompt_submit`, OTel `turnStart`)에서만 해제한다. terminal 이후 늦은 tool hook/OTel tool/activity span 은 무시해 끝난 thread 가 다시 `processing` 으로 살아나지 못하게 했다.
- Codex stale 정책을 분리했다: no-tool processing 30s idle settle, tool-bearing processing 120s idle settle, Codex idle observation 90s eviction, tool-bearing observation 240s eviction, post-terminal 60s eviction.
- OTel parser 가 slash/underscore span 이름을 정규화하고 현재 Codex span 이름과 activity span 을 인식한다. 숫자-only `session_id` 는 hook 과 동일하게 세션 id 로 승격하지 않는다.

### 검증

- `git diff --check` 성공
- `pnpm --filter @agentdeck/shared build` 성공
- `pnpm vitest run plugin/src/__tests__/session-slot-manager.test.ts` 성공 (5 tests)
- `pnpm --filter @agentdeck/shared typecheck` 성공
- `pnpm --filter @agentdeck/plugin typecheck` 성공
- `xcodebuild test -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -destination 'platform=macOS,arch=arm64' -only-testing:AgentDeckTests_macOS/CodexOtelParserTests -only-testing:AgentDeckTests_macOS/ProtocolTests -derivedDataPath /tmp/AgentDeckDerivedDataCodexLifecycle CODE_SIGNING_ALLOWED=NO` 성공 (첫 실행, Xcode test observer 991s 소요)
- 최종 comparator 경계 보정 후 `xcodebuild build -quiet -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS,arch=arm64' -derivedDataPath /tmp/AgentDeckDerivedDataCodexLifecycle CODE_SIGNING_ALLOWED=NO` 성공. 동일 test 재실행은 Xcode test runner 장시간 대기 반복으로 중단.

---

## 2026-05-01 — Codex phantom-creature fold + post-terminal TTL

### 문제

CLI (Node `agentdeck` daemon) 이 동작하지 않고 macOS App Store 빌드의 in-process Swift daemon 만 떠있는 환경에서, Dashboard 의 Terrarium 에 실제 동작 중인 Codex 세션 수보다 훨씬 많은 Codex 크리처가 노출되고 일부는 `processing` 상태로 "행동" 중인 것처럼 보였다. 원인 조사:

- `~/.codex/session_index.jsonl` 144개 entry 중 114개가 `Codex Companion Task: <task> Run a stop-gate review...` — Claude Code 의 stop-gate / rescue workflow 가 자동 spawn 한 ephemeral Codex 작업 (4월 29일 하루 27건).
- Codex 는 turn 마다 새 thread_id 를 발급하고 daemon 은 hook (`codex_session_start`/`codex_user_prompt_submit`/...) 또는 OTel `/otel/v1/traces` 두 합성 경로로 thread_id 별 entry 를 만든다. 한번 합성된 entry 는 `pushedSessionStaleTTL = 180s` 동안 살아남아 짧은 ephemeral task 가 연속으로 돌면 4-5개가 동시에 가시화됐다.
- Swift daemon 로그 (2026-04-29 23:00–23:04, 4분 윈도우) 에서 4개 distinct codex thread id 합성 → 모두 180s TTL 만료로 evict 되는 패턴 그대로 잡힘.

### 해결 (3-layer)

1. **Render-time fold by `(agentType=codex-cli, projectName)`** (`apple/AgentDeck/Terrarium/TerrariumState.swift:182-280`): cloud creature 를 프로젝트 키로 그룹핑해 한 워크스페이스 내 ephemeral burst 를 sprite 1개로 collapse. `CloudCreatureState.groupSize` 필드 추가. 빈 projectName 은 `__id__\(id)` 폴백으로 thread 별 분리. 같은 fold 를 PixooRenderer (`syncCreatures`) 에도 적용. Octopus(Claude Code) / OpenCode 는 fold 안 함 (multi-instance 가 의도된 패턴).
2. **Post-terminal TTL 60s** (`apple/AgentDeck/Daemon/Server/DaemonServer.swift`): `lastTerminalCodexEventBySession` 추가. `codex_stop`/`codex_turn_complete`/OTel `turnEnd` 에서 stamp, 비종료에서 clear. `evictStaleHookSessions()` 가 이 stamp 가 60s 이전이면 추가로 evict. 180s 무-hook TTL 은 never-terminated zombie 안전망으로 유지.
3. **Resurrection 범위 좁힘** (`shouldSynthesizeUnknownHookSession`): codex 분기에서 `codex_tool_start` 부활 제거. 부활은 `codex_session_start` + `codex_user_prompt_submit` 로 한정. `codex_user_prompt_submit` 은 인터랙티브 다중 턴 Codex 가 post-terminal TTL 로 evict 된 뒤 사용자가 다음 프롬프트를 보낼 때 복귀하는 경로라 유지 (live process 인데 dashboard 에서 사라진 채로 못 돌아오는 사례 차단). `codex_tool_start` 는 mid-turn 시그널이라 이미 끝난 thread 의 leftover 가능성이 큼.

### 검증

- 회귀 가드: `apple/AgentDeckTests/TerrariumCloudFoldTests.swift` (7 tests) — fold by project, 다른 project 는 분리, 빈 project over-merge 방지, state precedence, primary+sibling fold, octopus 비-fold, resurrection predicate trade-off.
- `xcodebuild build -project AgentDeck.xcodeproj -scheme AgentDeck_macOS -configuration Debug -destination 'platform=macOS'` **BUILD SUCCEEDED**.
- `xcodebuild build-for-testing -scheme AgentDeck_macOS -derivedDataPath …` **TEST BUILD SUCCEEDED**.
- `pnpm test` 47파일 1061테스트 통과 (Node bridge 무영향).

### 후속 (out-of-scope)

- D200H 버튼 fold (focus_session 이 thread 단위라 별도 설계 필요).
- `bridge/src/passive-observer.ts` 의 Node CLI 경로 mirror.

---
