--- name: agentdeck-deploy description: Build, install, launch, and configure AgentDeck on connected Android, Apple, ESP32, Stream Deck, Ulanzi Studio, or daemon targets. Use when the user asks to deploy AgentDeck or names target devices such as pantone, crema, lenovo, iphone, ipad, macos, esp32, tc001, d200h, bridge, daemon, or plugin. --- # AgentDeck Deploy Canonical deploy procedure for AgentDeck. This file is the single source of truth — `.claude/skills/agentdeck-deploy` is a tracked directory symlink to this skill. Build, install, launch, and configure AgentDeck on connected targets; keep deployment scoped to what is actually connected and skip missing devices with a clear note (never fail the whole deploy because one device is absent). ## Arguments / Target Names Parse the argument string to determine target(s). Multiple targets can be combined (e.g. `android ios`). | Argument | Scope | |----------|-------| | (none) / `all` | Full deploy: bridge → plugin → android → ios → macos | | `android` | All connected Android devices | | `pantone` / `pantone6` | Pantone 6 only | | `crema` | Crema S only | | `lenovo` / `tablet` / `tab` | Lenovo Tab only | | `ios` | Connected routine iOS targets (iPad Air M2 + iPhone 14 Pro Max) | | `iphone` | iPhone 14 Pro Max only | | `ipad` | iPad Air M2 only | | `macos` / `mac` | macOS app only | | `apple` | iOS + macOS | | `esp32` | All connected ESP32 display boards (excludes Ulanzi TC001) | | `esp32-all` | All ESP32 boards including Ulanzi TC001 | | `trmnl_75` | TRMNL 7.5" e-ink only | | `nm_epd_420` / `nm` | RockBase NM-EPD-420 only | | `lilygo_epd47` / `epd47` | LilyGo T5 ePaper S3 only | | `ulanzi` / `tc001` | Ulanzi TC001 LED matrix only | | `bridge` / `daemon` | Daemon restart only | | `plugin` / `sd` | Stream Deck plugin only | | `d200h` / `ulanzi-plugin` | Ulanzi Studio keypad plugin; separate from TC001 firmware | | `esp32_c6_147` | Waveshare C6-LCD-1.47; USB only, no OTA | The default `all` sequence above does not include ESP32 or the D200H Studio plugin. When reviewing release coverage, list those channels explicitly; when deploying, follow the requested target scope. `ulanzi` means TC001 firmware, whereas `d200h` / `ulanzi-plugin` means the Studio plugin. A firmware build or a plugin package does not update the installed runtime. ## Device Registry ### Android Devices | Device | Serial | Type | Quirks | |--------|--------|------|--------| | **Pantone 6** | `AA007422R24C1300039` | Color e-ink (Kaleido 3, RK3566) | Rotation reset on reinstall → must restore landscape. WRITE_SETTINGS permission lost on reinstall | | **Crema S** | `CREMAA21W09235` | B&W e-ink (IWG / Qualcomm sdm660, native Onyx View API) | Standard | | **Lenovo Tab** | `HVA095B4` | LCD tablet (J606F) | Standard | ### Apple Devices | Device | devicectl ID | xcodebuild destination | Type | |--------|-------------|----------------------|------| | **iPad Air 11" (M2)** | `8B71247D-A740-535E-8B2C-6FE9A196F342` | `platform=iOS,id=00008112-001608A02ED2601E` | WiFi/USB | | **iPhone 14 Pro Max** | `00008120-001169AA11D8C01E` | `platform=iOS,id=00008120-001169AA11D8C01E` | WiFi/USB | | **macOS** | — | `platform=macOS` | Local | iPhone XR is retired from routine test and deploy targets. Do not include it in `all`, `ios`, or `iphone` runs even if it appears in device discovery; target it only when the user explicitly requests that device. ### ESP32 Boards > **두 이름 층** (panel/form + 인치): `friendly` 는 `./scripts/flash.sh ` 에 넘기는 canonical 친근명, `pio env` 는 `pio run -e ` / `.pio/build//` 에 쓰는 실제 PlatformIO env. flash.sh 는 둘 다 받지만 `pio run -e` 는 **pio env 만** 받는다. | Board | friendly (flash.sh) | pio env (pio run -e) | Serial Pattern | Chip | |-------|------------|------------|----------------|------| | **86 Box** (480×480) | `box_40` | `box_86` | `/dev/cu.usbserial-*` | ESP32-S3, CH340 | | **IPS 3.5"** (480×320) | `ips_35` | `ips35` | `/dev/cu.usbmodem*` | ESP32-S3, native USB | | **Round AMOLED** (360×360) | `amoled_18` | `amoled` | `/dev/cu.usbmodem*` | ESP32-S3, native USB | | **TTGO T-Display** (135×240) | `tft_114` | `ttgo` | `/dev/cu.wchusbserial*` | ESP32-D0WDQ6, CH340 | | **IPS 10.1"** (800×1280) | `ips_101` | `ips10` | `/dev/cu.wchusbserial*` | ESP32-P4 + C6 | | **TRMNL 7.5"** (800×480 e-ink) | `trmnl_75` | `trmnl_75` | `/dev/cu.usbmodem*` | XIAO ESP32-S3 Plus | | **RockBase NM-EPD-420** (400×300 e-ink) | `nm_epd_420` | `nm_epd_420` | `/dev/cu.usbmodem*` | ESP32-S3 N16R8, native USB | | **LilyGo T5 ePaper S3** (960×540 e-ink) | `lilygo_epd47` | `lilygo_epd47` | `/dev/cu.usbmodem*` | ESP32-S3 N16R8, native USB | | **T-Embed CC1101** (170×320 + encoder) | `t_embed` | `t_embed` | `/dev/cu.usbmodem*` | ESP32-S3, native USB | | **T-Display-S3-Pro** (222×480) | `t_display_pro` | `t_display_pro` | `/dev/cu.usbmodem*` | ESP32-S3, native USB | | **Waveshare C6-LCD-1.47** (172×320) | `esp32_c6_147` | `esp32_c6_147` | `/dev/cu.usbmodem*` | ESP32-C6, native USB, no OTA | | **Ulanzi TC001** (8×32 LED) | `led_8x32` | `led8x32` | `/dev/cu.usbserial-*` | ESP32-D0WD classic, CH340 | ## Execution Steps **Output discipline.** Every step below runs from the repository root (`cd "$(git rev-parse --show-toplevel)"`). Send long build output to a log under `diagnostics/logs/` (gitignored) and show only the tail or the errors, e.g. `mkdir -p diagnostics/logs; >diagnostics/logs/.log 2>&1 || { tail -40 diagnostics/logs/.log; exit 1; }`, then `grep -nE 'error|FAILED' diagnostics/logs/.log | head -20` if the tail is not enough. Use each tool's quiet mode: xcodebuild `-quiet`, gradle `-q`, `pio run -s`. ### Step 0: Pre-flight — Detect Connected Devices Run before any deploy to know what's available: ```bash cd "$(git rev-parse --show-toplevel)" echo "=== ADB Devices ===" adb devices -l 2>/dev/null | grep -w device | grep -v "List" echo "=== Apple Devices ===" xcrun devicectl list devices 2>/dev/null | grep -E "(iPhone|iPad)" || echo "none" echo "=== Serial Ports (ESP32) ===" ls /dev/cu.usb* 2>/dev/null || echo "none" echo "=== Stream Deck ===" pgrep -x "Stream Deck" >/dev/null && echo "running" || echo "not running" echo "=== Daemon ===" cat ~/.agentdeck/daemon.json 2>/dev/null || echo "not running" ``` For macOS targets (`macos`, `apple`, `all`, or any run that ends in screenshots/E2E driven through System Events), also run `bash scripts/macos-preflight.sh --automation --accessibility --screen-recording --json` (add `--firewall apple/DerivedData/Build/Products/Debug/AgentDeck.app` once that app is built). Exit 2 means a grant is denied: relay each `fix` path to the user and stop the E2E part instead of letting a consent sheet stall it; exit 3 means it could not confirm — say so, never assume granted. Only deploy to devices that are actually connected. Skip missing devices with a warning, don't fail. If preflight access fails, follow `AGENTS.md` Agent working agreements for execution-policy failures; retry only the affected command after resolving access, and do not infer device absence from an unreadable probe. ### Step 1: Build (always first, unless target is bridge-only or esp32-only) ```bash mkdir -p diagnostics/logs pnpm build >diagnostics/logs/pnpm-build.log 2>&1 || { tail -40 diagnostics/logs/pnpm-build.log; exit 1; } ``` For Android targets, also build APK: ```bash bash scripts/build-android-release.sh >diagnostics/logs/android.log 2>&1 || { tail -40 diagnostics/logs/android.log; exit 1; } ``` This produces `dist/agentdeck-v{VERSION}.apk`. For Apple targets (ios/macos), build via xcodebuild (see Step 3/4). ### Step 2: Android Deploy For EACH connected Android device in the target set: ```bash SERIAL="" APK="dist/agentdeck-v*.apk" # use the actual versioned filename from Step 1 # 1. Stop running app adb -s $SERIAL shell am force-stop dev.agentdeck # 2. Install (attempt direct first, uninstall on signature conflict) if ! adb -s $SERIAL install -r $APK 2>&1 | grep -q "Success"; then adb -s $SERIAL uninstall dev.agentdeck adb -s $SERIAL install $APK fi # 3. Launch adb -s $SERIAL shell am start -n dev.agentdeck/.MainActivity # 4. adb reverse for daemon connection adb -s $SERIAL reverse tcp:9120 tcp:9120 ``` **Device-specific post-install:** **Pantone 6** (after ANY install, especially after uninstall→reinstall): ```bash # Rotation fix — reinstall resets system rotation settings (NOTE: Pantone 6 only! Do NOT run on Crema S or ordinary tablets) adb -s AA007422R24C1300039 shell settings put system accelerometer_rotation 0 adb -s AA007422R24C1300039 shell settings put system user_rotation 1 # 1=landscape ``` **Crema S**: No special steps needed. (Standard `requestedOrientation` is used; do not modify system-wide `user_rotation`.) **Lenovo Tab**: No special steps needed. ### Step 3: iOS Deploy Build once, install on multiple devices: ```bash cd "$(git rev-parse --show-toplevel)" # Build (one build serves both devices); a fixed derived-data path, never a DerivedData hash xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_iOS \ -destination 'platform=iOS,id=00008112-001608A02ED2601E' \ -derivedDataPath apple/DerivedData \ CODE_SIGN_STYLE=Automatic DEVELOPMENT_TEAM=QF36NDHYHD -quiet \ >diagnostics/logs/xcodebuild-ios.log 2>&1 || { tail -40 diagnostics/logs/xcodebuild-ios.log; exit 1; } APP=apple/DerivedData/Build/Products/Debug-iphoneos/AgentDeck.app ``` For EACH iOS device in the target set: ```bash DEVICE_ID="" xcrun devicectl device install app --device $DEVICE_ID $APP xcrun devicectl device process launch --device $DEVICE_ID bound.serendipity.agent.deck ``` **Important:** - Devices must be unlocked (passcode protected → install fails) - After uninstall→reinstall, local network permission popup appears again - Prefer existing XcodeBuildMCP tools when available; otherwise use these established `xcodebuild` commands ### Step 4: macOS Deploy ```bash cd "$(git rev-parse --show-toplevel)" xcodebuild build -project apple/AgentDeck.xcodeproj -scheme AgentDeck_macOS \ -destination 'platform=macOS' -derivedDataPath apple/DerivedData -quiet \ >diagnostics/logs/xcodebuild-macos.log 2>&1 || { tail -40 diagnostics/logs/xcodebuild-macos.log; exit 1; } # Kill existing → relaunch killall AgentDeck 2>/dev/null; sleep 0.5 open -a "$PWD/apple/DerivedData/Build/Products/Debug/AgentDeck.app" ``` Do not add or alter App Store UI text that asks users to install or launch external tools (App Review 4.2.3 — see `AGENTS.md` "App Store build invariants"). ### Step 5: Bridge/Daemon Restart Use the supervisor-routed lifecycle command (`.claude/rules/daemon-lifecycle.md`): `daemon restart` rebuilds stale packages on a checkout, restarts through the LaunchAgent/systemd/Scheduled Task that owns the daemon, and verifies the daemon that came up by pid and build — never background `daemon start &` with a fixed sleep. ```bash agentdeck daemon restart # Bounded health wait on the registry-resolved port (never a blind 9120 probe) PORT=$(sed -n 's/.*"port"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' ~/.agentdeck/daemon.json 2>/dev/null | head -n 1) for _ in 1 2 3 4 5 6 7 8 9 10; do curl -fsS --max-time 2 "http://127.0.0.1:${PORT:-9120}/health" >/dev/null && break sleep 1 done agentdeck daemon status ``` If the health wait never answers, report the daemon as unverified (not stopped) and show `agentdeck daemon status`. ### Step 6: Plugin A build is not a deployment: Marketplace installation can replace the source link, and an already-running process keeps its old JavaScript after a rebuild. On macOS, from the persistent main checkout, run `pnpm plugin:deploy`. It refuses linked worktrees and checkouts missing known `origin/master` changes, builds shared and plugin, preserves a replaced installation outside the host scan directory, links the source, restarts it, and requires a fresh runtime receipt matching the bundle path and SHA-256. `pnpm plugin:check` checks the current installation and running process without changing them. Never report success from `pnpm build` or a successful `streamdeck link` alone. After a Marketplace/DRM validation session, restore the development installation with `pnpm plugin:deploy` and record `pnpm plugin:check` before finishing. A failed switch restores the prior installation; do not delete its backup. ### Ulanzi Studio / D200H plugin Use `pnpm --filter @agentdeck/plugin-ulanzi package` to prepare a package; `package:install` additionally installs it into Studio when deployment is in scope. Read [the verification procedure](../../../plugin-ulanzi/VERIFY.md) and [the packaging contract](../../../.claude/rules/devices-and-wire.md#ulanzi-plugin-packaging). Retain WASM resvg and the bundled fonts. Restart the installed plugin and verify its path, version, bundle hash and process start after replacement, then inspect actual D200H keys and reconnect behaviour. A file hash alone does not prove the running process loaded it. D200X keypad support does not imply encoder support. ### Step 7: ESP32 Firmware **CRITICAL: Build and flash ONE AT A TIME** — PlatformIO lock + serial port conflicts. **Ulanzi TC001 is a separate target.** The `esp32` target deploys connected display boards from the [board SSOT](../../../shared/src/esp32-boards.ts), excluding TC001: 86 Box, IPS 3.5", IPS 10.1", Round AMOLED, TTGO, T-Embed CC1101, T-Display-S3-Pro, C6-LCD-1.47, TRMNL 7.5", NM-EPD-420, and LilyGo EPD47. Use `ulanzi`, `tc001`, or `esp32-all` to include the Ulanzi TC001. This separation exists because: - Ulanzi uses a different chip (ESP32-D0WD classic vs ESP32-S3) - Ulanzi uses FastLED matrix rendering, not LVGL — UI changes to cloud.cpp/theme.h don't affect it - Ulanzi requires a different flash procedure (esptool full-flash vs PIO upload) **CRITICAL: Identify boards by `device_info` BEFORE flashing.** Port numbers change when USB hub positions change — never assume a port number means a specific board. ```bash cd "$(git rev-parse --show-toplevel)/esp32" # Step 1: Detect and IDENTIFY each board for port in /dev/cu.usb*; do echo "=== $port ===" # Send device_info_request, read 1 line bounded by a perl alarm (stock macOS has no `timeout`) (echo '{"type":"device_info_request"}' > "$port" &) 2>/dev/null perl -e 'alarm shift; exec @ARGV' 2 head -n 1 < "$port" 2>/dev/null | grep -o '"board":"[^"]*"' || echo "no response" done # Match each port to its board name before flashing! ``` If the daemon is holding a serial port, stop the daemon (or use the flash helper that pauses/resumes it) before flashing — see `docs/esp32.md`. #### Display Boards (86 Box, IPS 3.5", Round AMOLED, T-Embed CC1101, T-Display-S3-Pro) Flash each detected display board: ```bash # Match port to environment and flash # run from esp32/, so the repo log dir is ../diagnostics/logs pio run -s -e -t upload --upload-port >../diagnostics/logs/pio-.log 2>&1 \ || tail -40 ../diagnostics/logs/pio-.log ``` **86 Box CH340 fallback**: If PIO upload fails at high baud (chip stops responding), build separately then flash with esptool at 115200: ```bash pio run -s -e box_86 # build only BOOT_APP0=~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin ~/.platformio/penv/bin/esptool --chip esp32s3 --port --baud 115200 \ --before default-reset --after hard-reset write-flash -z \ --flash-mode dio --flash-freq 80m --flash-size 16MB \ 0x0 .pio/build/box_86/bootloader.bin \ 0x8000 .pio/build/box_86/partitions.bin \ 0xe000 "$BOOT_APP0" \ 0x10000 .pio/build/box_86/firmware.bin ``` The flash size must match `board_upload.flash_size` (16MB since the 2026-07-05 dual-OTA migration): `8MB` patches the bootloader header below the partition table and the board boot-loops on `partition 3 invalid ... exceeds flash chip size`. `boot_app0.bin` resets otadata; without it the bootloader keeps booting the older OTA slot and the board reports the previous build (both measured 2026-09-26). **Stray serial readers**: with the Node daemon stopped, the macOS AgentDeck app's Swift daemon opens the boards' serial ports; two readers on one TTY show up as esptool "serial noise"/checksum failures. Quit the app (or keep the Node daemon up and use its `/esp32/serial/suspend` lease) before a USB write, and reopen it after. **IPS10 USB recovery**: identify the current CH340 port from `device_info`; the fixed port in platformio.ini may be stale. Python esptool 5.3 with `--before default-reset --after hard-reset` and its default stub works at 460800 (measured 2026-09-26, P4 rev1.3). Do not reuse the historical no-reset/no-stub recipe. Use 16MB/DIO/40MHz, bootloader at `0x2000`, partitions at `0x8000`, `boot_app0.bin` at `0xe000`, firmware at `0x10000`. Browser flashing is still unverified. Suspend serial with a lease while keeping the Node daemon running. **Boards with OTA**: TRMNL re-enumerates to a download node. Once a board has a dual-OTA partition table, `agentdeck esp32-ota -e --build` over WiFi is the simpler path; confirm the running build by a direct `device_info` read under a serial lease — the daemon's device list can keep reporting the previous build after an OTA. After flash: USB re-plug required for JTAG boards (IPS 3.5", Round AMOLED). #### Native e-ink boards All three must be identified by `device_info` before flashing because native USB port numbers change. TRMNL 7.5" additionally re-enumerates to a distinct download node; choose that new node after reset. NM and LilyGo use the confirmed 115200 upload speed. ```bash ./scripts/flash.sh trmnl_75 ./scripts/flash.sh nm_epd_420 ./scripts/flash.sh lilygo_epd47 ``` #### Ulanzi TC001 (separate target) **Only flash when `ulanzi`, `tc001`, or `esp32-all` is specified.** Skip for plain `esp32` target. Use the helper script or the equivalent `esptool` command at `115200`: ```bash cd "$(git rev-parse --show-toplevel)/esp32" ./scripts/flash.sh led_8x32 /dev/cu.usbserial-211110 ``` Equivalent manual fallback: ```bash cd "$(git rev-parse --show-toplevel)/esp32" ~/.platformio/penv/bin/esptool --chip esp32 --port /dev/cu.usbserial-211110 --baud 115200 \ --before default-reset --after hard-reset write-flash -z \ --flash-mode dio --flash-freq 40m --flash-size 8MB \ 0x1000 .pio/build/led8x32/bootloader.bin \ 0x8000 .pio/build/led8x32/partitions.bin \ 0xe000 ~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ 0x10000 .pio/build/led8x32/firmware.bin ``` Notes: - `460800` is not reliable on the TC001's CH340 path - always flash `bootloader + partitions + firmware` together - if the daemon is holding the serial port, the helper script pauses it and resumes it after flash ## Verification After all deploys complete, verify each target: ```bash # Android: check process running for d in AA007422R24C1300039 CREMAA21W09235 HVA095B4; do adb -s $d shell pidof dev.agentdeck 2>/dev/null && echo "$d: running" || echo "$d: not running" done # Daemon curl -s --max-time 2 "http://127.0.0.1:${PORT:-9120}/health" | head -c 300 # Plugin / Devices agentdeck devices 2>/dev/null ``` ## Output Print a summary table at the end. Example: ``` Deploy Summary ══════════════════════════════════════════════════ Target Status Details ────────────────────────────────────────────────── Bridge OK Daemon port 9120 Plugin OK Runtime hash verified Pantone 6 OK Installed + launched + rotation fix Crema S OK Installed + launched Lenovo Tab OK Installed + launched iPad Air OK Installed + launched iPhone SKIP Not connected macOS OK Built + launched ESP32 SKIP No firmware changes ══════════════════════════════════════════════════ ``` Use SKIP (not connected / not in target), OK (success), FAIL (error with reason). Always report which commands required approval. ## Error Handling - **Signature mismatch**: Auto-uninstall → reinstall. Warn user that app data is lost - **Device not connected**: SKIP with warning, don't fail the whole deploy - **Build failure**: Stop immediately, show error. Don't install stale APK - **iOS device locked**: Warn "device must be unlocked" and skip - **ESP32 upload hang**: Kill after 60s timeout, suggest esptool fallback