# PLAN_IOS_CLIENT.md — iOS 原生客户端（SwiftUI + SwiftTerm，"口袋驾驶舱"）

> 落地方案文档。目标：给 web-terminal 做一个 **iPhone 原生 App**，把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
> 拓扑/框架选型：**Phased-Native —— SwiftUI + SwiftTerm，4 个纯 SwiftPM 包 + 薄 App 胶水；前台会话单条活 WS，其余 HTTP 轮询；服务器零改动（P0 零触点；P1 仅声明的附加触点，见 §0.3）**。
> 状态：**规划中（2026-07-04，未开工）**。
> 本文是「怎么做」的蓝图，配合 [TECH_DOC.md](./TECH_DOC.md)（why）+ [ARCHITECTURE.md](./ARCHITECTURE.md)（how）；桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md)；远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)（查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG）。
> **G1 日志铁律**：`PROGRESS_LOG.md` 由 **orchestrator 独写**，不在任何任务的 `Owns:` 里；被派的 subagent **不写 LOG**，而是在最终返回消息末尾附上**可直接粘贴的日志条目**（状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步），由主会话统一追加。

---

## 0. 目标与范围

### 做什么

一个 iPhone 原生 App（iOS 17+，Swift 6 language mode），面向核心场景：**给 Claude Code 发个任务走开，手机把你叫回来，两次手势处理完**。

- **完整可交互终端**：SwiftTerm 渲染，attach 即回放 ring-buffer 全量 scrollback；服务器仍是"字节搬运工"，App 端 `feed()` 原样喂字节。
- **会话跨断线存活**：杀 App / 切后台 / 断网 → 重新 attach 即恢复；`sessionId` 按 host 持久化。**前台会话一条活 WS，其余会话靠 HTTP 轮询**。
- **一眼看全（glance list）**：会话列表 = chooser + dashboard 合并——状态点（working/waiting/idle/stuck）、telemetry 芯片（cost/context/PR，带 staleness TTL）、swipe-to-kill、下拉刷新。
- **远程审批（THE steering primitive）**：tool gate 的 Approve/Reject 横幅 + plan gate 三选一（Approve+Auto / Approve+Review / Keep Planning），gate 到达触发 haptics。
- **"离开期间发生了什么"digest**：重连后终端顶部渲染 away-digest（来源 `GET /live-sessions/:id/events`）。
- **主机找得到手机**：P0 复用**既有** ntfy 桥（`npm run setup-hooks` 已内置，`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装，零新代码，NEEDS-INPUT/DONE）；P1 换 APNs + 锁屏 Allow/Deny（notification action → **Face ID/通行码确认（`.authenticationRequired`）** → `POST /hook/decision`，仍不启动 App UI）。
- **移动键位补全**：native `inputAccessoryView` key-bar（Esc / Shift+Tab / 方向 / Ctrl-C…，字节表逐字节复刻 `public/keybar.ts`）+ 硬件键盘 `UIKeyCommand`。
- **配对即用**：扫 web UI 的 QR（`qr.ts` origin URL——**扫码结果是不可信外部输入，先显式确认解析出的 host 再发起任何网络请求**，见 T-iOS-12）或手输 URL；配对探针 + 可操作的错误话术（"Local Network 权限被拒""Origin 被拒——在主机加 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`，与 App 连接的 URL 一致"）。

### 不做（v1 范围外）

- **鉴权/登录、多用户隔离**：沿用现有威胁模型（TECH_DOC §7）——LAN-only + 推荐 Tailscale；严禁公网暴露。
- **relay / E2E 加密通道**：relay 栈仍 pre-production（无可跑服务端，见 [DEPLOY_RELAY.md](./DEPLOY_RELAY.md)），且**已审计的 TS 加密实现绝不能随手用 Swift 重写**（F6 这类 deterministic-nonce 交互连 99-agent 审计第一轮都漏了）。详见 §5.5。
- **WKWebView 套 xterm.js**：两位评审一致否决——WKWebView 卡顿与 content-process 被杀正是本 App 要逃离的痛。
- **iPad 优化布局、Live Activities、Widget、Mac Catalyst**：后续再议（Live Activities 依赖 APNs `liveactivity` push，P1 之后才有条件）。
- **后台常驻 WS**：iOS 切后台数秒即 suspend，做不到也不装能做到；设计基石就是 foreground-reattach。
- **前端/服务器重构**：`public/` 与 `src/` 一行不改（触点见 §0.3）。

### 已被验证掉的大风险（关键复用点，引现有代码为证）

服务器**早就为"多客户端、断线重连"设计好了**，iOS 客户端只是又一个说同一协议的端：

```ts
// 会话/连接解耦:WS 断开只 detach,PTY 继续跑;最后一个客户端离开才开始计 idle
// src/server.ts:791-800
// attach(sessionId) → 全量回放 ring-buffer snapshot() 再续实时流
// src/session/session.ts:158-170;回放前缀 soft-reset \x1b[0m(src/types.ts:167-170)
// JOIN(mirror)语义:新 attach 加入镜像,绝不踢掉其他客户端
// src/session/manager.ts:108-174;PTY 尺寸 latest-writer-wins(src/session/session.ts:200-211)
// 协议解析永不抛异常,非法帧静默丢弃 —— 客户端照抄这个韧性
// src/protocol.ts:45-86;src/server.ts:698-701
```

已核实的平台事实（详见任务内引用）：SwiftTerm v1.13+（MIT、SPM、商业 App 验证过）`TerminalViewDelegate.send/sizeChanged` 与本项目字节协议 1:1 对应；`URLSessionWebSocketTask` 可自定义 `Origin` header（不在 reserved-header 列表）——但此为 MED 置信度，**Day-1 spike 对真服务器实测**（T-iOS-2）。

### 服务器触点（server touch-points，学 relay 计划的惯例：声明而非隐藏）

本计划对本仓库 `src/`、`public/` 的改动**只有以下 P1 两处，均为增量；P0 零触点**：

| 阶段 | 触点 | 性质 |
|---|---|---|
| P0 | **零新代码** —— 复用**已随 `npm run setup-hooks` 发布**的 ntfy 桥（安装逻辑 scripts/setup-hooks.mjs:227-238，`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装：NEEDS-INPUT=high、DONE=low；env 已被 src/session/session.ts:99-100 转发进每个会话）。STUCK 不在 P0 信号列表（stuck 是服务器 `manager.sweepStuck` 派生态，无对应 Claude hook 事件，hook 桥发不出——推迟到 P1 APNs，走既有事件总线/NotifyService） | T-iOS-17 只**验证 + 写文档**，不建新文件 |
| P1 | `src/push/` 旁新增 **APNs sender**（~150 行，`.p8` + HTTP/2），复用现有 hook 事件总线；外加一个**增量** APNs device-token 注册端点（形状在 T-iOS-20 内定稿，`G` 守卫 + 限频，对齐 `POST /push/subscribe` 的既有约定 src/server.ts:461-480） | 增量文件 + 增量 route；`/hook/decision` 原样复用（src/server.ts:503-525） |
| P1 | `LiveSessionInfo` 增加可选 `lastOutputAt` 字段：`src/types.ts:246-256` 加一字段 + `src/session/manager.ts` `list()` 加一行映射（服务器本就逐 `pty.onData` 维护 `lastOutputAt`，src/types.ts:211/M3），供 T-iOS-23 unread 水位 | 增量字段 + 测试；TypeScript 任务 **T-iOS-37**（遵循根仓库 PLAN 工作流） |

除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷，修复归属对应模块（route/session 文件的 owner），按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。

---

## 1. 整体架构 / 进程模型

```
┌───────────────────────────── iPhone App（SwiftUI）─────────────────────────────┐
│                                                                                │
│  App/WebTerm(胶水层,排除在覆盖率门之外)                                           │
│   PairingScreen → SessionListScreen(合并 chooser+dashboard) → TerminalScreen    │
│      │                │                       │  +KeyBar +GateBanner           │
│      │                │                       │  +AwayDigestView               │
│      │        @MainActor @Observable ViewModel(消费 AsyncStream<SessionEvent>) │
│      ▼                ▼                       ▼                                │
│  Packages(纯逻辑,Sendable 不可变,80% 覆盖率门):                                   │
│   ┌────────────┐ ┌───────────┐ ┌──────────────────────────┐ ┌───────────────┐  │
│   │HostRegistry│ │ APIClient │ │ SessionCore              │ │ WireProtocol  │  │
│   │ Keychain + │ │ URLRequest│ │  SessionEngine(actor)    │→│ 冻结契约:      │  │
│   │ UserDefaults│ │ builders │ │  ReconnectMachine(纯SM)  │ │ Client/Server │  │
│   │ last-      │ │ +Pairing- │ │  PingScheduler·GateState │ │ Message enums │  │
│   │ sessionId  │ │  Error 分类│ │  AwayDigest reducer      │ │ MessageCodec  │  │
│   └────────────┘ └───────────┘ │  URLSessionTermTransport │ │ Validation    │  │
│                                └──────────────────────────┘ └───────────────┘  │
└──────────┬──────────────────┬──────────────────────┬───────────────────────────┘
   HTTP GET 轮询(RO,无 Origin)  HTTP 变更(G,带 Origin)   单条活 WS(仅前台会话:
   /live-sessions /events …    DELETE /live-sessions…   Origin header + 16 MiB
           ▼                  ▼                        maximumMessageSize+25s ping)
┌────────────────────── Mac 上的 web-terminal 服务器(零改动) ──────────────────────┐
│  isOriginAllowed(默认拒空 Origin) · /term WS · RingBuffer 回放 · JOIN mirror     │
└────────────────────────────────────────────────────────────────────────────────┘
```

**为什么"单条活 WS + HTTP 轮询"而不是每会话一个 connection actor**：iOS 切后台数秒内 suspend、socket 必死（Apple forums 716118），per-session 常驻连接是在跟平台打一场必输的仗，还会制造大量"看着连着其实死了"的僵尸状态。前台会话独占唯一活 WS，切会话 = detach + attach（服务器回放 ring-buffer，切换成本 ≈ 0）；后台会话的"有没有新动静"靠 `/live-sessions` 轮询（P0）→ APNs（P1）。这与服务器的 session/connection 解耦设计**严丝合缝**。接受的代价：APNs 落地前，后台会话的 unread 信号有轮询延迟（评审确认可辩护）。**升级路**：若未来要多会话并行盯屏，加第二条"观察者 WS"即可，SessionEngine 不用改。

**为什么 SwiftTerm 而不是 WKWebView + xterm.js**：原生渲染、原生手势与选择、软键盘/IME 全走系统栈；`TerminalViewDelegate.send(source:data:)` / `sizeChanged` / `feed(byteArray:)` 与 `input`/`resize`/`output` 帧 1:1。WKWebView 方案两位评审均否决（见 §0 不做）。SwiftTerm 历史上最麻烦的是自绘文本选择与 first-responder——Day-1 spike + 验收里专门压这块。

**为什么 URLSessionWebSocketTask 而不是 Starscream**：系统框架、iOS 13+ 内建；Starscream 最后一版 4.0.8 已 ~2 年未动，仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩：**`maximumMessageSize` 默认 1 MiB，而 ring-buffer 回放是单帧全量（`buffer.snapshot()`，src/session/session.ts:165-171），JSON 会把控制字节 `\uXXXX` 转义膨胀 1–6×（src/protocol.ts:186），最坏 ≈ 6 × SCROLLBACK_BYTES（默认 2 MiB）——必须设 `Tunables.maxWSMessageBytes = 16 MiB`（≥ 6×默认 + 帧包络）**；即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知（协议无 config 握手），**超限时 `receive()` 以 NSPOSIXErrorDomain code 40（EMSGSIZE，"Message too long"；T-iOS-2 spike 实测勘误——原文误标 ENOBUFS，Darwin ENOBUFS=55，归类以 40 为主、55 兜底）失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术（"服务器 scrollback 超过客户端上限，请调低 SCROLLBACK_BYTES 或调高客户端上限"），绝不喂进 backoff 重连循环**（否则确定性无限重试）；**`receive()` 一次只交付一条消息，必须循环 re-arm**，忘了就静默断流；**没有自动 ping，25 s 定时 `sendPing`**（`PingScheduler`）+ 显式 "reconnecting…" 横幅，终端绝不"看着连着其实死了"。

**为什么 4 个纯 SwiftPM 包 + 薄 App 胶水**：逻辑全部下沉到无 UIKit 依赖的包里（`WireProtocol`/`SessionCore`/`HostRegistry`/`APIClient`），`swift test` 秒级跑、80% 覆盖率门只量"值得量的逻辑"；App target 只剩 UIViewRepresentable、导航和 ViewModel 粘合。依赖方向**严格单向向下**，`WireProtocol` 是唯一冻结契约（对应 `src/types.ts` 的地位）——**共享 I/O 边界类型也在这里**（`HostEndpoint`/`TermTransport`/`TransportConnection`/`HTTPTransport`/`TimelineEvent`/`Tunables`，见 §3.1）：SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol，叶子包之间零耦合，W0 的 TestSupport 就能编译。

**为什么 v1 只做 LAN + Tailscale、不碰 relay**：Tailscale iOS App 是系统级 VPN（Network Extension），对本 App 完全透明——tailnet IP/MagicDNS 直接可达，**无 SDK、无一行网络代码**；服务器零改动。relay 栈尚无可部署的服务端，且把刚审计完的 TS 加密（X25519/HKDF/deterministic-nonce AEAD/epoch-in-key）移植成 Swift 等于重开所有 findings（§5.5）。**升级路**：relay 上线后优先评估 WKWebView 嵌 relay-web（复用已审计密码学字节），而非 Swift 重写。

**为什么合并 chooser + dashboard 成一张列表**：`GET /live-sessions` 一个响应里已经带 `status + telemetry`（src/types.ts:246-256, src/session/manager.ts:181-194），拆两个界面是自造 DRY 违约；一张列表 5 秒回答"要不要介入"。

---

## 2. 目录结构

新增独立 `ios/`（与 `desktop/` 同级同法），与 `src/`、`public/` **并列且解耦**——不污染根 `package.json`，不引入任何第三方运行时依赖（SwiftTerm 是唯一 SPM 依赖，且只挂在 App target）。

```
web-terminal/
├── src/  public/  desktop/        # 全部不动
└── ios/                           # ★ 新增
    ├── project.yml                # XcodeGen 声明式工程;.xcodeproj 不入库
    ├── .gitignore                 # DerivedData / *.xcodeproj / xcuserdata
    ├── Packages/
    │   ├── WireProtocol/          # 冻结契约(对应 src/types.ts + src/protocol.ts)+共享 I/O 边界类型
    │   │   ├── Package.swift
    │   │   ├── Sources/WireProtocol/
    │   │   │   ├── ClientMessage.swift    ├── ServerMessage.swift
    │   │   │   ├── MessageCodec.swift     ├── Validation.swift
    │   │   │   ├── WireConstants.swift    ├── Tunables.swift
    │   │   │   ├── HostEndpoint.swift     ├── TermTransport.swift   # 含 TransportConnection
    │   │   │   ├── HTTPTransport.swift    └── TimelineEvent.swift
    │   │   └── Tests/WireProtocolTests/
    │   │       ├── CodecRoundtripTests.swift
    │   │       ├── HostEndpointTests.swift   # originHeader/wsURL 派生向量
    │   │       └── ServerVectorTests.swift   # 从 test/protocol.test.ts 移植的跨实现向量
    │   ├── SessionCore/
    │   │   ├── Sources/SessionCore/
    │   │   │   ├── URLSessionTermTransport.swift
    │   │   │   ├── SessionEngine.swift       ├── SessionEvent.swift
    │   │   │   ├── ReconnectMachine.swift    ├── PingScheduler.swift
    │   │   │   ├── GateState.swift           ├── AwayDigest.swift
    │   │   │   └── KeyByteMap.swift          # 键位字节表(纯数据,T-iOS-11 所有)
    │   │   └── Tests/SessionCoreTests/…
    │   ├── HostRegistry/
    │   │   ├── Sources/HostRegistry/{Host,HostStore,KeychainHostStore,SecItemShim,InMemoryHostStore,LastSessionStore}.swift
    │   │   └── Tests/HostRegistryTests/…
    │   ├── APIClient/
    │   │   ├── Sources/APIClient/{APIClient,Endpoints,Models,PairingProbe,PairingError}.swift
    │   │   └── Tests/APIClientTests/…
    │   └── TestSupport/           # 测试替身(仅被各包 test target 依赖;仅依赖 WireProtocol)
    │       └── Sources/TestSupport/{FakeTransport,FakeClock,FakeHTTPTransport}.swift
    ├── App/WebTerm/               # 胶水层,排除在覆盖率门之外
    │   ├── WebTermApp.swift  DeepLinkRouter.swift(P1)
    │   ├── Screens/{PairingScreen,SessionListScreen,TerminalScreen}.swift
    │   ├── Components/{KeyBar,GateBanner,PlanGateSheet,AwayDigestView,ReconnectBanner,TelemetryChips}.swift
    │   ├── ViewModels/{TerminalViewModel,SessionListViewModel,PairingViewModel}.swift
    │   └── Resources/             # Assets;Info.plist 键在 project.yml 里声明
    └── IntegrationTests/          # CI 专用:对真 Node 服务器的 Swift Testing
        └── LiveServerTests.swift
```

**构建管线**（本地与 CI 同路径）：
1. `brew install xcodegen && cd ios && xcodegen generate` → `WebTerm.xcodeproj`（不入库）。
2. 各包独立测试：`swift test --package-path ios/Packages/<Pkg>`（纯 mac 侧，无模拟器，秒级）。
3. App 构建：`xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm -destination 'platform=iOS Simulator,name=iPhone 16' build test`。
4. 集成（CI，macOS runner）：根目录 `npm ci && PORT=0 … npm start` 起真服务器（临时端口 + `ALLOWED_ORIGINS`）→ `swift test --package-path ios/IntegrationTests`（详见 T-iOS-16 与 §7）。

---

## 3. 协议与连接核心契约（函数签名级，风格对齐 ARCHITECTURE §3）

> 以下签名是 W0 冻结的接口；实现细节归各任务。**不可变 + Sendable** 是硬约束：状态一律 struct 快照，变更返回新值；可变运行时句柄（socket task、URLSession）只活在 actor 内部。

### 3.1 `WireProtocol` — 冻结契约（对应 `src/types.ts:87-120` + `src/protocol.ts`）

```swift
// 伪代码 —— 不可变、显式错误处理(decode 失败 → nil,镜像服务器"非法帧静默丢弃"语义)
public enum ClientMessage: Sendable, Equatable {
  case attach(sessionId: UUID?, cwd: String?)   // 首帧必须是它(src/server.ts:707-711)
  case input(data: String)                      // 原始键盘字节,逐字节透传,不过滤
  case resize(cols: Int, rows: Int)             // 均为 1...1000 整数(src/protocol.ts:113-115)
  case approve(mode: ApproveMode?)              // mode 仅对 plan gate 有意义
  case reject
}
public enum ApproveMode: String, Sendable, CaseIterable { case `default`, acceptEdits, plan, auto }
// 注:plan gate 三选一只发 acceptEdits/default(镜像 public/tabs.ts:345-347 与 src/types.ts:84-86);
// raw `auto` 是被 ALLOW_AUTO_MODE 门控的高危模式(默认 false,src/config.ts:385,服务器会降级 auto→default,
// src/server.ts:765-766)——仅保留给未来的"权限模式切换器"(那里才按 uiConfig.allowAutoMode 过滤),本计划无任务消费它。

public enum ServerMessage: Sendable, Equatable {
  case attached(sessionId: UUID)                            // 永远采用服务器回发的 id
  case output(data: String)                                 // 不透明 ANSI/UTF-8,回放与实时同型
  case exit(code: Int, reason: String?)                     // code == -1 → spawn 失败,reason 必有
  case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
  case telemetry(StatusTelemetry)
}
public enum ClaudeStatus: String, Sendable { case working, waiting, idle, unknown, stuck }
public enum GateKind: String, Sendable { case tool, plan }
public struct StatusTelemetry: Sendable, Equatable, Decodable {
  // 全可选字段 + 必有 at(服务器 ms 时间戳)—— 镜像 src/types.ts:406-416
  public let contextUsedPct: Double?; public let costUsd: Double?
  public let linesAdded: Int?; public let linesRemoved: Int?
  public let model: String?; public let effort: String?
  public let pr: PrInfo?; public let rate: RateInfo?; public let at: Int
}

public enum MessageCodec {                       // 纯静态,永不 throw
  public static func encode(_ msg: ClientMessage) -> String        // → JSON 文本帧
  public static func decodeServer(_ text: String) -> ServerMessage? // 非法 → nil
}
public enum Validation {                         // 与服务器同规则(src/protocol.ts:22-23,113-115,142-149)
  public static func isValidSessionId(_ s: String) -> Bool   // UUID v4 正则,同 SESSION_ID_RE
  public static func isValidResize(cols: Int, rows: Int) -> Bool
  public static func isAbsoluteCwd(_ s: String) -> Bool      // 必须以 "/" 开头
}
public enum WireConstants {
  public static let wsPath = "/term"                          // src/config.ts:41
  public static let replaySoftResetPrefix = "\u{1B}[0m"       // src/types.ts:167-170
  public static let spawnFailedExitCode = -1
  public static let resizeRange: ClosedRange<Int> = 1...1000
}

// —— 共享 I/O 边界类型(同包冻结;SessionCore/HostRegistry/APIClient/TestSupport 只 import,不得另立) ——
public struct HostEndpoint: Sendable, Equatable, Codable {
  public let baseURL: URL          // http(s)://<dialed-host>:<port>,Origin 由它单点派生
  public var wsURL: URL { get }    // ws(s) 同 host 同 port + WireConstants.wsPath
  public var originHeader: String { get }
  // "<scheme>://<host>[:<port>]"——端口为 scheme 默认值(http/80、https/443)时省略,
  // 其余部分与 baseURL 逐字符一致(与浏览器 Origin 序列化一致)
}
public protocol TermTransport: Sendable {
  func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
}
public struct TransportConnection: Sendable {
  public let frames: AsyncThrowingStream<String, Error>   // 服务器 JSON 文本帧;结束/出错 → 断线
  public let send: @Sendable (String) async throws -> Void
  public let close: @Sendable () async -> Void
}
public protocol HTTPTransport: Sendable {   // URLSession 薄封装;FakeHTTPTransport(TestSupport)同实现
  func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
public struct TimelineEvent: Sendable, Equatable, Decodable {
  // 镜像 src/types.ts:428-433(GET /live-sessions/:id/events 条目);未知 class → 该条丢弃不 crash
  public let at: Int; public let `class`: String
  public let toolName: String?; public let label: String
}
public enum Tunables { /* 全部具名常量;值与出处见 §3.2.1 表。需新增常量 → 回 T-iOS-3 改契约 */ }
```

### 3.2 `SessionCore` — 连接核心

```swift
// 伪代码 —— HostEndpoint/TermTransport/TransportConnection/HTTPTransport/TimelineEvent/Tunables
// 定义在 WireProtocol(§3.1),本包只 import。TermTransport 是唯一 WS I/O 边界,FakeTransport(TestSupport)
// 与 URLSessionTermTransport 同实现此协议;SessionEngine 对二者不可区分。

public actor SessionEngine {                    // 每个"打开中的会话"一个 engine;前台仅一个持活
  public init(transport: any TermTransport, clock: any Clock<Duration>,
              endpoint: HostEndpoint, reconnect: ReconnectMachine = .initial,
              eventsSource: @Sendable (UUID) async throws -> [TimelineEvent])
              // eventsSource = 重连后 digest 的注入点(生产实现由 App 层用 APIClient.events 包一层;
              // 测试注入 fake)——engine 不直接持有 HTTP 客户端,保持 TermTransport 为唯一 WS 边界
  public nonisolated let events: AsyncStream<SessionEvent>   // UI 消费的唯一出口
  public func open(sessionId: UUID?, cwd: String?) async     // 连接→首帧 attach→回放→实时
  public func send(_ msg: ClientMessage) async               // input/resize/approve/reject
  public func notifyForegrounded(dims: (cols: Int, rows: Int)) async // 重连+attach+补发 resize
  public func close() async                                  // 显式 detach(PTY 继续跑)
}
public enum SessionEvent: Sendable, Equatable {
  case connection(ConnectionState)   // .connecting/.connected/.reconnecting(attempt:next:)/.closed
                                     // /.failed(FailureReason) —— 不可重试终态(如 .replayTooLarge:
                                     // receive() EMSGSIZE(40)/message-too-big,绝不进 backoff 重连,UI 给可操作话术)
  case adopted(sessionId: UUID)      // attached 帧;未知 UUID 会拿到新 id —— 永远采用它
  case output(String)                // 直接 feed 给 SwiftTerm(@MainActor hop 由 VM 负责)
  case exited(code: Int, reason: String?)
  case gate(GateState?)              // nil = gate 已解除
  case telemetry(StatusTelemetry)
  case digest(AwayDigest)            // 重连完成后由 engine 拉 /events 归纳一次
}

public struct ReconnectMachine: Sendable, Equatable {   // 纯状态机,注入 Clock,零真实计时
  public static let initial: ReconnectMachine
  public enum Input  { case connected, disconnected, retryTimerFired, foregrounded, userRetry }
  public enum Effect: Equatable { case connectNow, scheduleRetry(after: Duration), none }
  public func reduce(_ input: Input) -> (ReconnectMachine, Effect)
  // backoff 1s→2s→4s…封顶 30s(镜像 public/terminal-session.ts);connected 归零
}
public struct PingScheduler: Sendable {   // 25s sendPing;连续 2 次无 pong → 视为断线
  public init(interval: Duration = Tunables.pingInterval)
}
public struct GateState: Sendable, Equatable {
  public let kind: GateKind; public let detail: String?
  public let epoch: Int    // pending 上升沿 +1;approve/reject 带 epoch,陈旧操作丢弃(防误批新 gate)
}
public struct AwayDigest: Sendable, Equatable {
  public let toolRuns: Int; public let waitingCount: Int
  public let sawDone: Bool; public let sawStuck: Bool; public let recent: [TimelineEvent]
  public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest
}
```

#### 3.2.1 `Tunables` 取值表（唯一取值出处；Tunables.swift 在 WireProtocol，归 T-iOS-3）

| 常量 | 值 | 出处 / 说明 |
|---|---|---|
| `pingInterval` | 25 s | §1：URLSessionWebSocketTask 无自动 ping |
| `pongMissLimit` | 2 | 连续 2 次无 pong → 视为断线（T-iOS-5） |
| `listPollInterval` | 5 s | 镜像 web launcher 轮询节奏（public/launcher.ts:30 `REFRESH_MS = 5000`） |
| `telemetryStaleTtlMs` | 30_000 | 镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`（= 服务器默认 src/config.ts:63；服务器侧 env 可覆盖——iOS 固化默认值，主机改配置时会漂移，已接受） |
| `digestFadeDelay` | 8 s | T-iOS-14 digest 自动淡出 |
| `titleMaxLength` | 256 | T-iOS-23 OSC 标题净化上限 |
| `pairingProbeTimeout` | 10 s | 配对探针整体 deadline（§3.4 契约裁定新增；两步探针任一步挂起超此时限 → `.timeout`） |
| `maxWSMessageBytes` | 16 MiB（`16 * 1024 * 1024`） | ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。**耦合警示（写进 doc comment）**：回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数（1–6×，控制字节→`\uXXXX`，src/protocol.ts:186）；SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`（§3.2 / T-iOS-9/10） |

### 3.3 `HostRegistry`

```swift
public struct Host: Sendable, Equatable, Codable, Identifiable {
  public let id: UUID; public let name: String; public let endpoint: HostEndpoint
}
public protocol HostStore: Sendable {                 // Keychain 实现 + InMemory 替身(都在本包)
  func loadAll() async throws -> [Host]
  func upsert(_ host: Host) async throws -> [Host]    // 返回新集合(不可变风格)
  func remove(id: UUID) async throws -> [Host]
}
// KeychainHostStore 经 SecItemShim 协议封装 SecItem* 调用(可测性缝):unsigned `swift test` 二进制
// 用不了 data-protection keychain(SecItemAdd → -34018 errSecMissingEntitlement),故 swift test 层
// 测 store 逻辑对 fake shim;真 Keychain 路径 + kSecAttrAccessible 属性断言放 xcodebuild 模拟器测试(签名宿主)。
public protocol LastSessionStore: Sendable {          // UserDefaults 实现(非机密)
  func lastSessionId(host: UUID) -> UUID?
  func setLastSessionId(_ id: UUID?, host: UUID)
}
```

### 3.4 `APIClient`

```swift
public struct APIClient: Sendable {
  public init(endpoint: HostEndpoint, http: any HTTPTransport)   // HTTPTransport 定义在 WireProtocol,可注入替身
  // RO(无 Origin):
  public func liveSessions() async throws -> [LiveSessionInfo]   // GET /live-sessions
  public func preview(id: UUID) async throws -> SessionPreview   // GET /live-sessions/:id/preview
  public func events(id: UUID) async throws -> [TimelineEvent]   // GET /live-sessions/:id/events
  public func uiConfig() async throws -> UiConfig                // GET /config/ui {allowAutoMode}
                                                                 // 预留:未来权限模式切换器用(那里才按
                                                                 // allowAutoMode 过滤 raw auto);plan gate 不消费
  // G(必带 Origin,src/server.ts:332-339):
  public func killSession(id: UUID) async throws                 // DELETE /live-sessions/:id
  public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws
}
// Origin 铁律:仅 G 端点 stamp `Origin: endpoint.originHeader`;RO GET 一律不带 ——
// 这样一旦服务器把某 RO 端点改为 G,集成测试立刻红,而不是靠巧合通过。
public enum PairingError: Error, Equatable {   // [S:hybrid] 错误分类学,逐项映射探针失败模式
  case localNetworkDenied      // POSIX "Network is down" + LAN IP → 引导去设置开权限
  case hostUnreachable(underlying: String)
  case httpOkButNotWebTerminal // GET /live-sessions 非预期形状 → "端口对吗?"
  case originRejected(hint: String) // WS 401 → "在主机加 ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port],与 App 连接的 URL 一致"
  case atsBlocked(host: String)     // NSURLErrorDomain -1022(ATS 拦明文) → "该 IP 段不在 App 例外列表,
                                    // 用 https/tailscale serve,或反馈该网段"(§5.2 例外列表之外的明文目标)
  case tlsFailure, timeout
}
public func runPairingProbe(endpoint: HostEndpoint, http: any HTTPTransport,
                            ws: any TermTransport) async -> Result<HostEndpoint, PairingError>
// 【契约裁定 2026-07-04，T-iOS-8 BLOCKED 上报】原冻结签名返回 Result<Host,…>,但 Host 是 §3.3
// HostRegistry 类型,APIClient 依赖边只有 WireProtocol(§1 叶子包零耦合)——签名自相矛盾。
// 裁定:探针职责=验证 endpoint,返回 HostEndpoint;Host{id,name} 由 T-iOS-12 PairingViewModel
// 构造后入 store(id/name 本就非探针所知)。超时经 Tunables.pairingProbeTimeout(§3.2.1 新增行)。
// 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返
//(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。
// 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。
```

### 3.5 App 胶水（不冻结，示意）

`TerminalViewModel`（`@MainActor @Observable`）：持有 `SessionEngine`，`for await event in engine.events` 把 `output` `feed()` 给 SwiftTerm、把 `connection/exited` 转成 UI 状态；实现 `TerminalViewDelegate.send` → `engine.send(.input(…))`、`sizeChanged` → `engine.send(.resize(…))`。`GateViewModel`（独立 VM，T-iOS-14）消费同一 events 流的 `.gate/.digest`（T-iOS-15 接线）。Swift 6 strict concurrency 编译期强制 socket→main 的 hop。`scenePhase == .active` → `engine.notifyForegrounded(dims:)`（重连 + 补发 resize，latest-writer-wins 夺回全屏）；`scenePhase != .active` → 隐私遮罩（T-iOS-15）。

---

## 4. 工程标准（每个任务的硬性门槛）

- **TDD 强制**：每个任务 RED（先写失败测试，来自 Steps(测试)）→ GREEN（最小实现）→ REFACTOR。测试与实现同属一个任务、一个 agent。
- **覆盖率 ≥ 80%**：unit + integration + E2E 三类都要；门只对 4 个 Packages 计（App 胶水排除）。
- **不可变**：绝不原地 mutate；状态为不可变快照，变更返回新副本；可变运行时句柄（socket/URLSession/TerminalView）单独持有在 actor / @MainActor 类内。
- **文件 200–400 行典型，硬上限 800**；多小文件、按 feature 组织。
- **函数 < 50 行**，单一职责。
- **早返回**优先，嵌套 > 4 层禁止。
- **无魔法数字**：阈值/延迟/上限一律具名常量（`Tunables.pingInterval`、`Tunables.maxWSMessageBytes = 8 * 1024 * 1024`…）。
- **无硬编码配置/密钥**：用户可见配置进 Settings；必需配置启动即校验、fail fast。
- **系统边界全验证**：**把服务器当不可信输入源**——每一帧过 `MessageCodec`/`Validation` 白名单，非法 → 丢弃（不 crash、不猜）；用户输入（URL、扫码结果）同样先验证。
- **错误处理全面显式**：UI 给用户友好话术（PairingError 分类学、ReconnectBanner），内部日志留细节；绝不静默吞错。
- **Conventional commits**（`feat:`/`fix:`/`test:`…），提交边界对齐任务。
- **每任务完成即 code review**（CRITICAL/HIGH 必须修完才算完）；网络/输入/Keychain 代码追加 security review。

---

## 5. 安全考量（沿用现有威胁模型）

### 5.1 Origin header（原生客户端的第一道坎）

`isOriginAllowed` **对 `undefined`/空 Origin 默认拒绝**（src/http/origin.ts:26-29——非浏览器客户端天然被拒），且要求 scheme+hostname+port **逐项精确匹配**白名单（src/http/origin.ts:47-51）。白名单由**主机网卡 IPv4 + 端口**派生（不是 bindHost）：`http(s)://localhost:<port>`、`127.0.0.1`、各非内网卡 IP，再并入 `ALLOWED_ORIGINS` env（src/config.ts:187-226）。因此：

- App 必须在 **(a) WS 升级**（否则 401，src/server.ts:646-651）与 **(b) 所有 `G` 类变更 HTTP**（否则 403，src/server.ts:332-339）上显式携带 `Origin: <scheme>://<host>[:<port>]`——端口为 scheme 默认值（http/80、https/443）时省略，其余部分与实际连接 URL 逐字符一致（与浏览器 Origin 序列化一致），由 `HostEndpoint.originHeader` 单点派生，禁止手拼。
- RO GET **一律不带** Origin（见 §3.4 铁律）。
- Tailscale/TLS 场景：`ALLOWED_ORIGINS=<scheme>://<App 拨号的 host>[:port]`——填 App 实际连接的 URL 即可。`isOriginAllowed` 对**两侧**都做 `new URL()` 规范化后比对 protocol/hostname/port（src/http/origin.ts:31-51），https 默认端口写不写 `:443` 均匹配（WHATWG URL 把默认端口规范化为空串，双向对称）。PairingError 话术只引导"加与拨号 URL 一致的 `ALLOWED_ORIGINS`"，不引入端口迷信。

### 5.2 ATS 与 Local Network（Info.plist 精确键，只开最小口子）

```yaml
# project.yml 内声明(release 构建;debug 可临时 NSAllowsArbitraryLoads,严禁进 release ——
# 注意:debug 全放开会掩盖缺段问题,五段 CIDR 是否齐全必须在 release ipa 层核对,见 T-iOS-19)
NSAppTransportSecurity:
  NSAllowsLocalNetworking: true          # 只覆盖 .local / 无点主机名,不覆盖裸 IP
  NSExceptionDomains:                    # 裸 IP 用 CIDR 例外(iOS 17+ 语义)
    "192.168.0.0/16": { NSExceptionAllowsInsecureHTTPLoads: true }
    "10.0.0.0/8":     { NSExceptionAllowsInsecureHTTPLoads: true }
    "172.16.0.0/12":  { NSExceptionAllowsInsecureHTTPLoads: true }   # RFC1918 第三段(iPhone 热点 172.20.10.x/企业内网)
    "100.64.0.0/10":  { NSExceptionAllowsInsecureHTTPLoads: true }   # Tailscale CGNAT
    "127.0.0.0/8":    { NSExceptionAllowsInsecureHTTPLoads: true }   # 模拟器 dev-loop(用 CIDR 而非单 IP 键——
                                                                     # loopback 单 IP 键有不匹配史,DevForums 6205)
NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器"  # iOS 18+ 缺失则提示异常
NSCameraUsageDescription: "扫描 web 终端的配对二维码"   # P0 必需:T-iOS-12 用 DataScannerViewController,
                                                       # 缺失 = 打开扫码即 TCC crash
```

> P2 前置：T-iOS-31（语音 PTT）开工前需另加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`（属于该任务的前置，不属于 P0）。

- MagicDNS 名（`*.ts.net`）是 FQDN → ATS 全额适用 → 用 100.x IP、加例外域，或 `tailscale serve`（https/wss，最优）。
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down"；映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络（iOS 18 有需重启的已知 bug，话术里写明）。

### 5.3 凭据与本地存储

- **Keychain**：host 列表（含未来 authMaterial 占位）——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`，不进 iCloud 同步。
- **UserDefaults**：仅非机密（per-host lastSessionId、UI prefs）。
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**（服务器侧本就单次有效+过期，src/server.ts:503-525；限频 10/min/IP）；App 端绝不落盘。
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。

### 5.4 继承的威胁模型（TECH_DOC §7）

无鉴权 = **谁能连上端口谁就有 shell**。App 不改变这一点，只继承部署纪律：**绝不要**把服务器端口 port-forward/隧道到公网——后果是任何人拿到你的 shell；推荐 Tailscale（设备注册 + WireGuard E2E + 端口对公网不可见）。**标准部署话术推荐 `tailscale serve`（wss）**——绕开全部 ATS 例外与明文嗅探面（开放项 5 已定）。

配对/确认界面的提示**按 scheme + 地址类别分层**（TECH_DOC §7 明文嗅探/MITM 行的完整继承——`ws://` 在任何不可信 LAN 上暴露键击（密码/API key/Claude token）与全部输出给同网段被动嗅探与 ARP-spoof MITM；Origin 校验对此零防护）：

| 目标 | 提示 |
|---|---|
| https/wss | 无 |
| ws:// → 100.64.0.0/10 或 *.ts.net（MagicDNS） | 无明文警告（WireGuard 网络层已加密）；可选正向"经 Tailscale 加密"徽标 |
| ws:// → loopback | 无 |
| ws:// → RFC1918/link-local | **非阻断明文提示**：流量未加密、键击可被同网嗅探，仅限可信 LAN，优先 `tailscale serve`（wss） |
| http/ws → 公网（非 RFC1918，https 也含在"公网 host"确认警告内，见 T-iOS-12） | **最强阻断式警告**（醒目、需显式确认） |

### 5.5 relay / E2E：显式推迟（为什么不现在做）

relay 栈（term-relay/agent/relay-e2e/relay-auth…）是 pre-production 库集合：无可跑服务端进程、F6 replay 路径未端到端接线、Phase 0 集成未开工（[DEPLOY_RELAY.md](./DEPLOY_RELAY.md)、[PLAN_RELAY_RUN_PHASE0.md](./PLAN_RELAY_RUN_PHASE0.md)）。更关键的：relay-e2e 的密码学（X25519 握手、HKDF 方向分key、`nonce=f(seq)` 确定性 AEAD、epoch-in-key 回放）刚经 99-agent 对抗审计修完 F1–F6——**用 Swift 重写 = 重开每一个已关闭的 finding，且一条既有回归测试都带不走**（F6 恰是第一轮审计漏掉的那类交互）。故 v1 远程 = LAN + Tailscale；relay 上线后先评估 WKWebView 嵌 relay-web（字节级复用已审计 TS），Swift 移植仅在有跨语言 KAT + 独立审计预算时考虑。

---

## 6. 多 Agent 并行规则与波次

并行三条铁律（同 [PLAN.md](./PLAN.md) §0，违反会互相踩踏）：

1. **文件所有权独占**：每任务 `Owns:` 独占创建/修改权。`ios/Packages/WireProtocol/**` 由 **T-iOS-3 独占并冻结**，其余任务只读 import；要加共享类型 → 回 T-iOS-3 改契约，不得各自另立。
2. **只依赖接口不依赖实现**：`Depends:` 指"需要对方接口/产物"；§3 的签名在 W0 冻结，故多数任务可与依赖方并行编码，集成时汇合。
3. **G1**：LOG 由 orchestrator 独写；subagent 返回可粘贴条目（见页首）。

subagent **不能问用户、不能互相通信**：遇到本文+ARCHITECTURE+TECH_DOC 未定义的歧义 → 停下返回 `[!] BLOCKED`，**绝不猜**。单批并行控制在 **~3–5 个 agent**。验收任务 report-only（G4）：findings 标 severity + owning task，修复派回该任务的 builder。

```
W0 基础(串行;T-iOS-2 与 T-iOS-3 可在 T-iOS-1 后并行)
  T-iOS-1 脚手架 → T-iOS-2 Day-1 双 spike ∥ T-iOS-3 WireProtocol 契约(冻结,含共享 I/O 边界类型+Tunables) → T-iOS-4 测试替身
        │
        ▼
W1 叶子包(全部并行,互不依赖)
  T-iOS-5 Reconnect+Ping · T-iOS-6 Gate+Digest · T-iOS-7 HostRegistry · T-iOS-8 APIClient+探针
        │
        ▼
W2 连接核心
  T-iOS-9 URLSessionTermTransport → T-iOS-10 SessionEngine(可先按 §3 接口并行编码,集成汇合)
        │
        ▼
W3 UI 胶水(全部并行;只依赖 §3 接口 + FakeTransport)
  T-iOS-11 Terminal+KeyBar · T-iOS-12 Pairing · T-iOS-13 SessionList · T-iOS-14 Gate/Digest UI
        │
        ▼
W4 集成(汇合点;T-iOS-16 仅依赖 W2、T-iOS-17 零依赖——二者可提前并入第 6 批,见 §8)
  T-iOS-15 App 接线+生命周期 · T-iOS-16 集成 CI(真 Node 服务器) · T-iOS-17 ntfy 桥验证
        │
        ▼
W5 验收(report-only, 并行)
  T-iOS-18 F 走查(真机) · T-iOS-19 安全核对(对照 TECH_DOC §7 + 本文 §5)
```

---

## 7. 任务清单

> 状态图例：`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定，永不重编号。
> 每个任务自带 TDD（测试与实现同 agent 同文件组）。测试框架：Swift Testing（`@Test`/`#expect`）；XCTest 仅 XCUITest。
> 覆盖率验证命令（各包）：`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`（阈值 80%，见 §9）。

### P0 — 每日可用（"口袋里能开终端、能批准"）· 合计 ~13 人天

#### W0 · 基础（串行）

#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[ ]` · ~0.5 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/project.yml`、`ios/.gitignore`、5 个 `Package.swift` 空壳（4 个 gated 包 + `ios/IntegrationTests`；TestSupport 的 manifest 归 T-iOS-4 的 `**` glob）、`ios/App/WebTerm/WebTermApp.swift`（空窗）、CI workflow 骨架（`.github/workflows/ios.yml`）
- **Depends**: 无 · **Parallel-safe**: 无（必须最先）
- **Steps**:
  - [ ] `project.yml`：App target（iOS 17 floor、Swift 6 language mode、strict concurrency、default MainActor isolation）+ 4 个本地 SPM 包引用 + SwiftTerm v1.13+（SPM，仅 App target）
  - [ ] Info.plist 键全部经 `project.yml` 声明：**§5.2 的全部键**（ATS/五段 CIDR/NSLocalNetworkUsageDescription/NSCameraUsageDescription——枚举以 §5.2 为准，不得漏项；release 无 `NSAllowsArbitraryLoads`）
  - [ ] `xcodegen generate` 产物 gitignore；CI 骨架跑 `swift test`（0 测试也过）
- **Accept**: `xcodegen generate && xcodebuild … build` 通过；空 App 在模拟器启动
- **安全注**: ATS 键从本文 §5.2 逐字誊写，不许"先放开回头再收"。

#### T-iOS-2 · Day-1 双 spike（评审强制）`[ ]` · ~1 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/IntegrationTests/OriginSpikeTests.swift`、`ios/App/WebTerm/Screens/SpikeTerminalScreen.swift`（临时文件，W4 删）
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
- **Steps(测试先行——spike 本身就是测试)**:
  - [ ] `OriginSpikeTests`（对本仓库真服务器 `npm start`）：① 无 Origin 的 WS 升级收 **401**（src/server.ts:646-651）② `Origin: http://127.0.0.1:<port>` 精确匹配 → 升级成功、`attach(null)` 收到 `attached` ③ `maximumMessageSize` 默认 1 MiB 时灌 >1 MiB 输出再 reattach → **复现失败**；设 `Tunables.maxWSMessageBytes`（16 MiB）→ 回放成功；**追加对抗用例**：预灌 ESC/C0 控制字节密集输出（JSON `\uXXXX` 转义膨胀 ~6×）再 reattach → 仍成功 ④ 端口不匹配的 Origin（如 `Origin: http://127.0.0.1:9999`）→ **401**（src/http/origin.ts:47-51 端口精确比对；注意默认端口如 `:443` 会被 `new URL()` 双向规范化、**不是**失配案例）
  - [ ] 真机 smoke（人工清单，结果记录进返回条目）：SwiftTerm 键盘弹出/first-responder、中文 IME 组合输入、`inputAccessoryView` 原型上 Esc/Ctrl-C 可发、文本选择不崩
- **Accept**: 4 条自动化断言绿（其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录）；真机清单逐项有结论
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"（MED 置信度）的一锤定音；若失败 → `[!] BLOCKED`，orchestrator 决策切 Starscream 备胎，**不得自行引入依赖**。

#### T-iOS-3 · `WireProtocol` 契约包（冻结，含共享 I/O 边界类型）`[ ]` · ~1.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/WireProtocol/**`（Sources + Tests 全部，含 `HostEndpoint/TermTransport/HTTPTransport/TimelineEvent/Tunables`）
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
- **Steps(测试先行, RED)** — `Tests/WireProtocolTests/CodecRoundtripTests.swift`、`HostEndpointTests.swift`、`ServerVectorTests.swift`:
  - [ ] 5 种 `ClientMessage` encode 后的 JSON 与服务器 `parseClientMessage` 接受的形状逐键一致（`attach` 必含 `sessionId` 键，null 也要显式，src/protocol.ts:132-134）
  - [ ] `decodeServer` 5 种合法帧解出对应 case；`attached` 的 sessionId 非 UUID → nil
  - [ ] 非法帧全表 → nil 且不 throw：坏 JSON、未知 type、`resize` cols=0/1001/非整数、`input.data` 非 string、缺字段
  - [ ] `Validation.isValidSessionId`：合法 UUID v4 过；`abc123`、UUID v1、大写混合按服务器正则同判（向量从 `test/protocol.test.ts` 移植）
  - [ ] `isAbsoluteCwd("/a")==true`、`("a")==false`、`("")==false`
  - [ ] `telemetry` 帧全可选字段缺省可解、`at` 缺失 → nil
  - [ ] roundtrip property：任意合法 ClientMessage `encode→(服务器视角)decode` 不变形——**`approve.mode` 除外**：服务器 `parseClientMessage` 刻意丢 mode、对一切 approve 帧返回裸 `{type:'approve'}`（src/protocol.ts:77-79），mode 由 WS 接线层对**原始帧**二次解析顶层 `mode` 键恢复（src/server.ts:91-102）。approve 只断言"形状被接受"
  - [ ] 显式向量：`encode(.approve(mode:))` 必须把 `mode` 放**顶层键**、值 ∈ {default,acceptEdits,plan,auto}（因为 src/server.ts:94-102 读的是原始帧的 `obj['mode']`，不是解析后消息）
  - [ ] `HostEndpoint` 派生向量（原 T-iOS-7 用例移入本包）：`http://192.168.1.5:3000` → originHeader 同串；https+非标端口保留端口；**https+443 → 无端口后缀**；**http+80 → 无端口后缀**；wsURL 派生 scheme http→ws / https→wss + `/term`
  - [ ] `TimelineEvent` 解码：合法条目 + 未知 `class` → nil（消费方丢弃该条）
- **Steps(实现, GREEN)**: [ ] §3.1 全部类型与函数（含 `Tunables` 常量表 §3.2.1 落地）[ ] 完成后**冻结**：新增类型/常量必须回本任务改
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿；覆盖率 ≥ 80%
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash（用随机字节 fuzz 一轮）。

#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~0.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/TestSupport/**`（含本包 `Package.swift`，仅声明对 WireProtocol 的依赖——故 W0 即可编译）
- **Depends**: T-iOS-3（接口） · **Parallel-safe**: W1 全部
- **Steps**: [ ] `FakeTransport`（实现 WireProtocol 的 `TermTransport`；可手动灌帧 `emit(frame:)`/`emitError`、记录 send/close 调用）[ ] `FakeClock`（手动推进）[ ] `FakeHTTPTransport`（实现 WireProtocol 的 `HTTPTransport`，按 URL 排队响应）[ ] 各带 1 条 smoke 测试（`InMemoryHostStore` 归 T-iOS-7 的 HostRegistry 包——HostStore 协议在 W1 才存在）
- **Accept**: `swift test --package-path ios/Packages/TestSupport` 全绿

#### W1 · 叶子包（全部并行）

#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{ReconnectMachine,PingScheduler}.swift`、`Tests/…/{ReconnectMachineTests,PingSchedulerTests}.swift`
- **Depends**: T-iOS-3、T-iOS-4（FakeClock） · **Parallel-safe**: T-iOS-6/7/8
- **Steps(测试先行, RED)**:
  - [ ] 断线序列 → 重试延迟 1s,2s,4s,8s,16s,30s,30s（封顶）
  - [ ] `connected` 输入 → backoff 归零，下次断线从 1s 重来
  - [ ] `foregrounded`/`userRetry` → 立即 `connectNow`，不等定时器
  - [ ] reduce 是纯函数：同输入同输出；原值不被改（Equatable 断言旧快照）
  - [ ] PingScheduler：25s 触发 ping；1 次 pong 丢失容忍、连续 2 次 → 发出 disconnected 信号；FakeClock 推进驱动，测试 0 真实等待
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`（WireProtocol，T-iOS-3 所有；值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约，无魔法数字）
- **Accept**: `swift test --package-path ios/Packages/SessionCore --filter Reconnect` 等全绿

#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
- **Steps(测试先行, RED)**:
  - [ ] status 帧 `pending:false→true` 上升沿 epoch +1；同一 pending 持续不再 +1
  - [ ] 携带过期 epoch 的 approve → 判定丢弃（防止批到"下一个 gate"）
  - [ ] `gate:'plan'` 与 `'tool'` 分别映射三选一/两选一 affordance 数据
  - [ ] digest reduce：events 里 3 tool + 1 waiting + done → `{toolRuns:3, waitingCount:1, sawDone:true}`
  - [ ] `since` 过滤：早于离开时刻的事件不计入
  - [ ] `limit` 截断 recent；空 events → 全零 digest（UI 可据此不渲染）
- **Accept**: 对应 filter 全绿；reducer 纯函数、不可变

#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/HostRegistry/**`（含 `SecItemShim.swift` 与测试替身 `InMemoryHostStore.swift`——放 Sources，供本包与 App 层 VM 测试 import）
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/6/8
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约；Keychain 实现经 `SecItemShim` 缝测：
  - [ ] upsert 新 host → 集合含它；同 id 再 upsert → 替换不重复
  - [ ] remove 不存在的 id → 集合不变、不 throw 隐患（显式结果）
  - [ ] `KeychainHostStore` 逻辑对 fake `SecItemShim`：add/update/delete 走对分支、错误码显式映射（**不可**在 `swift test` 直连真 Keychain——unsigned 测试二进制对 data-protection keychain 必得 `-34018 errSecMissingEntitlement`；省掉 `kSecUseDataProtectionKeychain` 又会落到 legacy 文件 keychain、验证不了 §5.3 语义）
  - [ ] lastSessionId 存取/清除
  -（`HostEndpoint` originHeader/wsURL 派生向量已移入 T-iOS-3 的 WireProtocol 测试——本包不再定义该类型）
- **Steps(实现, GREEN)**: [ ] `SecItemShim` 协议 + 真实现（`kSecUseDataProtectionKeychain` + AfterFirstUnlockThisDeviceOnly）[ ] UserDefaults LastSessionStore [ ] 真 Keychain 路径 + `kSecAttrAccessible` 属性断言放 **xcodebuild 模拟器测试**（签名宿主 App，T-iOS-15/16 管线）
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿（覆盖率计法见 §9：KeychainHostStore 以 shim 注入计入）
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走；review 时对照 §5.3。

#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
  - [ ] **Origin 出现当且仅当 G 端点**：`liveSessions/preview/events/uiConfig` 请求无 Origin header；`killSession/hookDecision` 有且逐字符等于 `endpoint.originHeader`
  - [ ] `LiveSessionInfo` 解码：全字段样本 + `telemetry` 缺失样本（src/types.ts:246-256 形状）
  - [ ] `TimelineEvent` 解码 + 未知 `class` 值 → 该条丢弃不 crash（服务器视为不可信）
  - [ ] `hookDecision` body 形状 `{sessionId,decision,token}`；403 → 显式错误（token 过期话术）
  - [ ] 探针①失败分支：连接拒绝 → `hostUnreachable`；返回 HTML → `httpOkButNotWebTerminal`
  - [ ] 探针②失败分支：WS 401 → `originRejected(hint:…)`（hint 含 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`，与 App 连接的 URL 一致——不含任何 ":443 迷信"）
  - [ ] 探针全通 → `Result.success(HostEndpoint)`（契约裁定：Host 由 T-iOS-12 VM 构造，见 §3.4 注）；探针成功路径里 attach(null) 后必发 kill（不留孤儿会话）
  - [ ] 超时（FakeClock）→ `.timeout`
- **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment（按端点精确）：`hookDecision` body ≤ 4 KB（src/server.ts:503）、≤10 次/分/IP（src/server.ts:72,504-508）——P1 增量端点的限额归 T-iOS-38
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿；覆盖率 ≥ 80%
- **安全注**: G/RO 分界是安全语义（§5.1），测试名里写明"iff"。

#### W2 · 连接核心

#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~1 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/URLSessionTermTransport.swift`、`Tests/…/URLSessionTermTransportTests.swift`
- **Depends**: T-iOS-2（spike 结论）、T-iOS-3 · **Parallel-safe**: T-iOS-10（接口并行）
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo（测试内起 `NWListener` 或复用 IntegrationTests 服务器）:
  - [ ] 升级请求含 `Origin` header 且等于 endpoint.originHeader
  - [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`（16 MiB）——直接断言 task 配置
  - [ ] receive 循环持续 re-arm：连发 100 帧全部到达、顺序不乱
  - [ ] 服务器关闭（close frame）→ frames stream finish；错误 → stream throw（两种可区分）
  - [ ] receive 失败 NSPOSIXErrorDomain code 40（EMSGSIZE，"Message too long"——T-iOS-2 spike 实测；原文 ENOBUFS 为笔误，55(ENOBUFS) 作兜底同判——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭）→ stream throw **类型化 `.replayTooLarge` 错误**（供 engine 识别为不可重试）
  - [ ] `close()` 后 send → 显式错误，不 crash
  - [ ] 非文本（binary）帧 → 丢弃并继续收（服务器只发文本帧，但不信任它）
- **Steps(实现, GREEN)**: [ ] `URLSessionWebSocketDelegate`（didOpen/didClose 驱动状态，不靠 receive error 猜）[ ] ping 由 PingScheduler 注入驱动
- **Accept**: filter 全绿；T-iOS-16 的真服务器测试是它的最终验收
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`，本文件出现字符串拼接 origin = review CRITICAL。

#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~1.5 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/{SessionEngine,SessionEvent}.swift`、`Tests/…/SessionEngineTests.swift`
- **Depends**: T-iOS-3/4/5/6（接口）、T-iOS-9（集成汇合） · **Parallel-safe**: T-iOS-9
- **Steps(测试先行, RED)** — 全部对 FakeTransport：
  - [ ] `open()` 后**首帧必为 attach**，之前的 send 排队不越序（src/server.ts:707-711 语义）
  - [ ] `attached` 帧 → `adopted(sessionId:)` 事件；**未知 UUID 拿到新 id 时采用新 id**（src/session/manager.ts:166-173）
  - [ ] 回放→实时顺序：attach 后灌 3 帧 output → events 按序交付、无丢帧
  - [ ] `exit(code:-1, reason:)` → `exited` 事件且 engine 停止重连（spawn 失败不重试）
  - [ ] 断线 → `connection(.reconnecting(attempt:next:))` 事件流 + FakeClock 推进后自动重连、重新 attach **同一 sessionId**
  - [ ] `notifyForegrounded(dims:)` → 重连后补发 `resize`（latest-writer-wins）；已连接时只发 resize 不重连
  - [ ] gate 时序：status(pending:true) → `gate(GateState(epoch:1))`；approve 后 pending:false → `gate(nil)`；epoch 过期的 approve 不发送
  - [ ] 重连成功后拉 events 归纳 → 恰好一次 `digest` 事件（经 init 的 `eventsSource` 参数注入 fake events 源，§3.2）
  - [ ] transport 抛 `.replayTooLarge` → `connection(.failed(.replayTooLarge))` 事件、**停止重连**（不可进 backoff 循环——否则确定性无限重试）
  - [ ] `close()` → transport.close 被调、events stream finish、无泄漏 task（用 confirmation 断言）
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] attach 后立即补发一次 resize（服务器 80×24 spawn，src/server.ts:714-717）
- **Accept**: `swift test --package-path ios/Packages/SessionCore` 全绿；SessionCore 包覆盖率 ≥ 80%
- **安全注**: 每帧过 `MessageCodec.decodeServer`，nil → 丢弃计数（日志），绝不 crash。

#### W3 · UI 胶水（全部并行；只依赖 §3 接口 + 替身）

#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/TerminalScreen.swift`、`Components/{KeyBar,ReconnectBanner}.swift`、`ViewModels/TerminalViewModel.swift`、`SessionCore/Sources/SessionCore/KeyByteMap.swift` + `SessionCore/Tests/…/KeyByteMapTests.swift`（字节表为纯数据，源与测试都在包内——本任务是 W3 唯一持有 SessionCore 文件者：T-iOS-12/13/14 不碰 SessionCore、W1/W2 的 SessionCore owner 已完工，并行安全；纯数据+测试计入 SessionCore 覆盖率门，只帮不损）
- **Depends**: T-iOS-10（接口） · **Parallel-safe**: T-iOS-12/13/14
- **Steps(测试先行, RED)**:
  - [ ] 字节表逐键对照 `public/keybar.ts`：Esc=`\u{1B}`、Esc·Esc、Shift+Tab=`\u{1B}[Z`、↑↓←→=`\u{1B}[A/B/D/C`、Enter=`\r`（**不是 `\n`**）、Ctrl-C=`\u{03}`、Ctrl-R/O/L/T/B/D、Tab=`\t`、`/`
  - [ ] VM：engine 发 `output` → `feed` 调用在 MainActor（编译期由 Swift 6 保证，测试断言转发次序）
  - [ ] VM：`connection(.reconnecting)` → banner 状态量；`.connected` → 隐藏
  - [ ] VM：`connection(.failed(.replayTooLarge))` → 不再重试的错误态 + 可操作话术（"服务器 scrollback 超过客户端上限，请调低 SCROLLBACK_BYTES 或调高客户端上限"）
  - [ ] VM：`exited` → 终端只读 + exit 提示状态
- **Steps(实现, GREEN)**: [ ] `UIViewRepresentable` 包 `SwiftTerm.TerminalView`，delegate `send`→`engine.send(.input)`、`sizeChanged`→`.resize` [ ] KeyBar 为 `inputAccessoryView`，直发 engine（绕开 TerminalView 避免软键盘弹出逻辑干扰）[ ] 硬件键盘 `UIKeyCommand` 同映射 [ ] KeyBar 按钮与 `UIKeyCommand` 的标签→字节解析**一律经 `KeyByteMap` 常量**（单一事实源，镜像 `public/keybar.ts`）[ ] IME：不加自己的 keydown 拦截（SwiftTerm 自管 composition）
- **Accept**: 字节表测试全绿；模拟器人工冒烟（真机压在 T-iOS-18）

#### T-iOS-12 · `PairingScreen`（QR + 手输 + 探针 UI）`[ ]` · ~0.5 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/PairingScreen.swift`、`ViewModels/PairingViewModel.swift`
- **Depends**: T-iOS-7/8（接口） · **Parallel-safe**: T-iOS-11/13/14
- **Steps(测试先行, RED)** — VM 层（探针逻辑已在 T-iOS-8 测过，这里测状态映射）:
  - [ ] 每种 `PairingError` → 对应内联话术与"去设置/重试"动作（`localNetworkDenied` → 打开设置深链；`atsBlocked` → "明文 HTTP 被 ATS 拦截——该 IP 段不在 App 例外列表内，请用 https/`tailscale serve` 或反馈该网段"）
  - [ ] **扫码 → `confirmingHost` 确认态**：展示解析出的 `scheme://host:port`（经 `HostEndpoint` 单点解析，禁止手拼），**用户未点"连接"前对该 host 零网络请求**（FakeHTTPTransport/FakeTransport 断言零调用——探针①就会联网、②会在目标机 spawn 会话，扫码内容是不可信外部输入）
  - [ ] 确认后才：跑两步探针 → 成功 → host 入 store + 跳转列表
  - [ ] 扫码结果非 http(s) URL → 拒绝并提示（输入边界验证）
  - [ ] 警告分层（§5.4，**在确认页展示**）：公网 host（**http/https 均**——RFC1918、100.64/10、loopback、`.local`、`*.ts.net` 视为私网级，其余一律公网警告）→ 醒目警告；`ws://`+RFC1918/link-local → 非阻断明文嗅探提示；100.64/10 或 `*.ts.net` → 无明文警告（Tailscale 豁免）；loopback → 无
- **Steps(实现, GREEN)**: [ ] `DataScannerViewController`（真机 only，模拟器隐藏入口；依赖 §5.2 `NSCameraUsageDescription`）读 web UI `qr.ts` 的 origin URL [ ] 手输表单 fallback（用户自己输入的 URL 身份已知，可直连探针；复用确认态亦可）[ ] 多 host 切换入口（列表页 header 用）
- **Accept**: VM 测试全绿；模拟器手输路径可配对本机服务器

#### T-iOS-13 · `SessionListScreen`（合并 chooser + dashboard）`[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/SessionListScreen.swift`、`Components/TelemetryChips.swift`、`ViewModels/SessionListViewModel.swift`
- **Depends**: T-iOS-8（接口） · **Parallel-safe**: T-iOS-11/12/14
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
  - [ ] 轮询节奏：前台每 `Tunables.listPollInterval`（5 s，§3.2.1）拉一次 `/live-sessions`；离开页面停止（无泄漏 timer）
  - [ ] 状态点映射 5 态 + `pending:true` → ⚠ 徽标优先
  - [ ] telemetry staleness：`at` 距今 > `Tunables.telemetryStaleTtlMs`（30 s，镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`）→ 芯片置灰
  - [ ] swipe-to-kill → `DELETE /live-sessions/:id`（带 Origin）→ 乐观移除 + 失败回滚
  - [ ] 列表排序 newest-first 保持服务器顺序；`exited:true` 会话分组置底
  - [ ] "+ New session" → 进入 TerminalScreen 且 `open(sessionId:nil)`
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态（无会话/未配对）
- **Accept**: VM 测试全绿

#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift`、`ViewModels/GateViewModel.swift`（**独立 VM**，`@MainActor @Observable`，消费 SessionEvent 的 `.gate/.digest`——不是 TerminalViewModel 的扩展，与 T-iOS-11 真并行；接入 TerminalScreen 的 wiring 归 T-iOS-15）+ 对应 Tests
- **Depends**: T-iOS-6/10（接口） · **Parallel-safe**: T-iOS-11/12/13
- **Steps(测试先行, RED)**:
  - [ ] `gate(kind:.tool)` → 两键横幅（Approve/Reject）；`.plan` → 三选一 sheet（Approve+Auto / Approve+Review / Keep Planning）
  - [ ] 三选一映射（**镜像 public/tabs.ts:345-347 与 src/types.ts:84-86**）：Approve+Auto→`approve(mode:.acceptEdits)`、Approve+Review→`approve(mode:.default)`、Keep Planning→`reject`。**无 allowAutoMode 门**——web 端从不在 plan gate 上做此门控；`acceptEdits` 不受 SEC-M5 auto 降级影响（降级只打 raw `auto`，src/server.ts:765-766）；raw `auto` 仅保留给未来权限模式切换器（§3.1 注）
  - [ ] gate 到达 → haptic 触发一次（同 gate 不重复震）
  - [ ] digest 事件非全零 → 顶部渲染摘要行；全零 → 不渲染；点击展开 recent 明细
- **Steps(实现, GREEN)**: [ ] `UINotificationFeedbackGenerator` [ ] digest 自动淡出（`Tunables.digestFadeDelay` = 8 s）、可手动展开
- **Accept**: VM/映射测试全绿

#### W4 · 集成（汇合点）

#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~0.5 pd
- **Wave/阶段**: W4 / P0 · **Owns**: `App/WebTerm/WebTermApp.swift`（改）、导航组装、删除 T-iOS-2 的 SpikeTerminalScreen
- **Depends**: T-iOS-11–14 全部 · **Parallel-safe**: T-iOS-16/17
- **Steps**: [ ] Pairing→List→Terminal 导航 + 依赖注入（真实现 wiring；含 `GateViewModel`（T-iOS-14）接入 TerminalScreen）[ ] `scenePhase == .active` → `engine.notifyForegrounded(dims:)`（重连 + 补发 resize；**这是"换设备夺回全屏"的关键**）[ ] `.background` → 主动 `close()`（干净 detach，不留半死 socket）[ ] **隐私遮罩**：`scenePhase != .active` 时终端覆盖不透明遮罩、`.active` 恢复（**必须用 `!= .active`，不能只判 `.inactive`**——覆盖切换器进入的 .inactive 与快照发生的 .background 两态；iOS 后台快照会把终端内容（API key/token/源码）写盘并展示在多任务切换器）[ ] 冷启动：有 lastSessionId 的 host → 列表页高亮"继续上次"
- **Accept**: 模拟器全流程手工走查：配对→列表→attach→后台→前台重连回放；**切后台开切换器 → 卡片显示遮罩而非终端内容**
- **安全注**: 组装点核对一次：所有 G 调用来自 APIClient（无绕过）、debug ATS 设置未漏进 release scheme。录屏暴露面：iOS 无公开 API 把窗口排除出截屏/录屏——只可选做 `UIScreen.isCaptured` 检测（录屏时可选拉黑终端）；除此之外记为已接受残余风险（本地信任模型），**不得**计划 isSecureTextEntry 层这类非受支持 hack。

#### T-iOS-16 · 集成 CI（对真 Node 服务器）`[ ]` · ~0.5 pd
- **Wave/阶段**: W4 / P0（仅依赖 W2——可提前并入第 6 批，见 §8） · **Owns**: `ios/IntegrationTests/**`（含吸收 T-iOS-2 的 OriginSpikeTests）、`.github/workflows/ios.yml`（改）
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-11–15/17
- **Steps(测试清单)** — macOS runner：`npm ci` → 临时端口 `npm start`（`ALLOWED_ORIGINS` 注入）→ Swift Testing：
  - [ ] attach(null) → attached → input `echo hi\r` → output 含 `hi`
  - [ ] resize(120,40) 后 `stty size` 输出 `40 120`（整数边界 1/1000 各一发）
  - [ ] 灌 >1 MiB 输出 → 断开 → 重 attach → 回放完整（16 MiB 上限的端到端回归）；**追加对抗用例**：ESC/C0 控制字节密集回放（JSON `\uXXXX` 转义膨胀 ~6×）→ 仍完整
  - [ ] 双客户端 JOIN mirror：A、B 同 attach，A input，B 收 output
  - [ ] 无 Origin → 401；错 Origin → 401；DELETE 无 Origin → 403（G 守卫回归）
  - [ ] `DELETE /live-sessions/:id`（kill）→ 镜像客户端观察到 **WS close**（**不是** `exit` 帧——`manager.killById` 先 `ws.close()` 全部客户端再 kill，src/session/manager.ts:202-212，`sendIfOpen` 只投给 OPEN socket）且该 id 从 `GET /live-sessions` 消失
  - [ ] 自然退出广播路径：客户端 A input `exit\r` → 镜像客户端 B 收到 `{type:'exit'}` 帧（socket 仍开时 pty onExit 广播，src/session/session.ts:146-151）
  - [ ] **覆盖率门接线**：`ios.yml` 对 4 个包跑 §9 覆盖率循环，任一包 line coverage < 80% → job 红
- **Accept**: CI job 绿；覆盖率门**演示过一次红**（故意把某包压到 80% 下或抬高阈值）再回绿；这是"客户端复刻的协议契约"的持续防漂移闸门
- **安全注**: 本任务是 §5.1 的自动化化身；任何"为了过 CI 放宽 Origin 断言"= CRITICAL。

#### T-iOS-17 · ntfy 桥验证 + 文档（P0 临时通知，**零新代码**）`[ ]` · ~0.1 pd
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README 的 ntfy 章节（文档；**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货：安装逻辑 scripts/setup-hooks.mjs:227-238、env 门 :262-264，重装时 marker 自清理，见 §0.3）
- **Depends**: 无（与 App 解耦） · **Parallel-safe**: T-iOS-15/16
- **Steps**: [ ] 验证既有桥：设 `WEBTERM_NTFY_URL` + `WEBTERM_NTFY_TOPIC`（可选 `WEBTERM_NTFY_TOKEN`）→ `npm run setup-hooks` → 确认日志 "Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)" [ ] README 写明：env 未设即完全无副作用（默认关闭，已是出货行为）；topic 生成建议随机串（topic 即密码）[ ] 记录：**STUCK 不在 P0 信号内**（服务器 sweepStuck 派生态、无 hook 事件，桥发不出——P1 APNs 经事件总线补上）
- **Accept**: 手机装 ntfy App 订阅 topic → 触发 held gate → 手机收到通知；P1 APNs 落地后 README 标注可停用
- **安全注**: 复核既有 payload 最小化（sessionId 短前缀 + 状态词，不含 cwd/命令内容；token 绝不进命令字面量——SEC-C6 已保证）。

#### W5 · 验收（report-only，G4：只报告不改码，findings 标 owning task 派回）

#### T-iOS-18 · 验收走查 F-iOS-1…13（真机）`[ ]` · ~0.25 pd
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码（report-only）
- **Steps**: 按 §9 验收脚本逐条执行、记录结论与录屏。
#### T-iOS-19 · 安全核对 `[ ]` · ~0.25 pd
- **Depends**: T-iOS-15 · **Owns**: 无源码(report-only)
- **Steps**: [ ] 对照 TECH_DOC §7 + 本文 §5 逐条核：Origin 单点派生、G/RO 分界、ATS 键 release 实况（拆 ipa 验 Info.plist——**五段 CIDR 逐段核对**，debug `NSAllowsArbitraryLoads` 会掩盖缺段）、隐私 usage description 与实际使用的 capability **一一对应**（相机/本地网络；P2 加麦克风/语音识别——拆 ipa 核对）、Keychain 属性（模拟器测试断言 `kSecAttrAccessible`）、ntfy payload 最小化、无硬编码 host/密钥、警告分层文案在位（公网阻断 + RFC1918 明文提示 + Tailscale 豁免）、**真机核：切后台开切换器 → 快照卡片是遮罩非终端内容**。

**P0 合计 ≈ 13 人天**（0.5+1+1.25+0.25 / 0.5+0.5+0.5+1 / 1+1.5 / 1+0.5+1+1 / 0.5+0.5+0.1 / 0.25+0.25）。

---

### P1 — walk-away 完整（"锁屏上两次手势搞定"）· 合计 ~17 人天

> 波次：**W6（服务器触点：T-iOS-20 ∥ T-iOS-37，串行入库各自 repo 流程）与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20（payload 形状）、T-iOS-23 依赖 T-iOS-37（lastOutputAt 字段），其余 W7 任务（T-iOS-22/24/25/27/28/29）不等 W6；T-iOS-38（APIClient P1 契约增量）在 W7 首发，T-iOS-21/26 依赖它 → W8（验收）。任务粒度与 P0 同规格；此处 Steps(测试) 列关键用例，细化在开工时由任务 owner 补足（不改接口）。

#### T-iOS-20 · server: APNs sender + token 注册端点 `[ ]` · ~2 pd
- **Wave**: W6 · **Owns**: `src/push/apns.ts`（新）、`src/server.ts` 增量 route、`test/push-apns.test.ts`（**服务器触点，TypeScript 任务**，遵循根仓库 PLAN 工作流）
- **Depends**: 无 · **Parallel-safe**: T-iOS-37/38 及 W7 除 T-iOS-21 外全部（接口先行）
- **Steps(测试先行)**: [ ] `.p8` 缺失 → 功能整体 disabled、启动不 crash [ ] hook 事件 → APNs payload 形状（含 `/hook/decision` 用的 capability token + category）[ ] token 注册端点：G 守卫 403、限频 429、幂等注册/注销 [ ] 与既有 web-push 并行互不干扰
- **Steps(实现)**: [ ] HTTP/2 到 `api.push.apple.com`，`.p8` 走 env 路径（无硬编码密钥）[ ] 复用 `src/push/` 的事件订阅点
- **安全注**: capability token 语义不变（单次、过期、10/min 限频，src/server.ts:503-525）；APNs payload 不含命令内容。

#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[ ]` · ~0.25 pd
- **Wave**: W6 · **Owns**: `src/types.ts` 的 `LiveSessionInfo` 增量字段、`src/session/manager.ts` `list()` 一行映射（并更新其 "omitted by design" 注释）、对应测试增量（**声明的服务器触点，TypeScript 任务**，遵循根仓库 PLAN 工作流；见 §0.3）
- **Depends**: 无 · **Parallel-safe**: T-iOS-20、W7 全部
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`（服务器已逐 `pty.onData` 维护，src/types.ts:211/M3——只是序列化出来）[ ] 旧客户端兼容：字段为**新增可选**，web 前端不受影响
- **Accept**: 根仓库 `npm test` 全绿；T-iOS-23 的 unread 水位有数据源

#### T-iOS-38 · `APIClient` P1 契约增量（W7 首发，其余 W7 任务的 APIClient 单一 owner）`[ ]` · ~0.5 pd
- **Wave**: W7（首发） · **Owns**: `ios/Packages/APIClient/**` 的 **全部 P1 增量**（APNs token 注册 builder、`/projects`、`/projects/detail?path=`、`GET/PUT /prefs` builders 与解码 + Tests）——W7 期间 APIClient 文件**只有本任务可改**（对齐 T-iOS-3 冻结契约模式，避免 T-iOS-21/26 同波踩踏）
- **Depends**: T-iOS-8；T-iOS-20（仅 token 注册 builder 的端点形状——可接口先行并行编码，形状定稿时汇合） · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29（均不碰 APIClient 文件）
- **Steps(测试先行)**: [ ] token 注册 builder（G，带 Origin）[ ] projects/detail/prefs builders（PUT 带 Origin）与解码 [ ] doc comment 端点约束：push subscribe body ≤ 8 KB、≤5 次/分/IP（src/server.ts:73,461-466）；`PUT /prefs` ≤ 64 KB（src/server.ts:278）
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿；覆盖率 ≥ 80%

#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 Tests（APIClient 的 token 注册 builder 归 T-iOS-38）
- **Depends**: T-iOS-20（payload 形状）、T-iOS-38（token 注册 builder） · **Parallel-safe**: T-iOS-22–29
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**（锁屏批准 = 授权主机执行命令；旁观者拿到锁屏手机只能 Deny——fail-safe。断言注册 category 的 Allow 选项含 `.authenticationRequired`，且两动作均**不含** `.foreground`）[ ] action handler：从 payload 取 `{sessionId, token}` → `POST /hook/decision`（带 Origin）→ **不启动 App UI**（Face ID 设备上"两次手势 + 一瞥"闭环）[ ] token 用后即弃不落盘 [ ] 决策失败（token 过期 403）→ 补一条本地通知提示进 App 处理
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——**无 extension 参与**（本工程没有 notification extension target；Service Extension 只能改写来押通知、收不到 action tap）。handler 用 `beginBackgroundTask/endBackgroundTask` 包住 POST，请求完成/失败后才调 completion handler；失败必须可见（本地通知兜底），绝不静默吞。

#### T-iOS-22 · DeepLinkRouter `[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`：UUID v4 校验（复用 `Validation`），非法 → 忽略并留日志 [ ] 未知 host id → 落到配对页并提示 [ ] 冷/热启动两路径都直达 gated 会话 [ ] push tap → 同一路由
- **安全注**: deep link 是外部输入——全字段白名单校验，绝不据此直接拼 URL 请求。

#### T-iOS-23 · 多会话切换器（unread dots + OSC 标题）`[ ]` · ~2 pd
- **Wave**: W7 · **Owns**: `SessionListScreen` 增强（**W7 内该文件唯一 owner**，含 T-iOS-29 移交的列表侧入口）、`SessionCore` 的 unread 记账（`UnreadLedger.swift`）、标题净化器（`TitleSanitizer.swift`）+ Tests
- **Depends**: T-iOS-37（`lastOutputAt` 字段） · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] 单活 WS 不变：切会话 = close→open，回放恢复 [ ] unread 判定：`/live-sessions` 快照的 `lastOutputAt`（T-iOS-37 新增字段）> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入，入列表前过净化器**：截断 `Tunables.titleMaxLength`(256)；剥 Unicode 双向覆写与零宽字符（U+200B–200F、U+202A–202E、U+2066–2069——OSC 字符串解析已排除 C0，真正的仿冒向量是 bidi/零宽）；渲染用 `Text(verbatim:)`（绝不走 LocalizedStringKey/Markdown）+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试：超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感（回放解析在后台、feed 在 main）

#### T-iOS-24 · Timeline sheet（完整时间线钻取）`[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
- **Steps(测试先行)**: [ ] `/live-sessions/:id/events` 全量渲染（class → 图标/颜色映射）[ ] timeline disabled（空数组）→ 空态而非错误 [ ] 从 digest "展开"入口进入

#### T-iOS-25 · Quick-reply chips + 常用语面板 `[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/QuickReply.swift`、本地存储（UserDefaults）+ Tests
- **Steps(测试先行)**: [ ] chip 点击 → `input` 帧（文本 + `\r`）[ ] 自定义面板增删改序 [ ] waiting 状态才浮出（对齐 web `quick-reply.ts` 行为）

#### T-iOS-26 · Projects：列表 + 详情 + 在仓库起 Claude `[ ]` · ~2 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Tests（projects/detail/prefs 的 APIClient builders 归 T-iOS-38，本任务只消费）
- **Depends**: T-iOS-38（builders） · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
- **Steps(测试先行)**: [ ] VM 消费 `/projects`、`/projects/detail?path=`、`GET/PUT /prefs`（builder 与解码测试在 T-iOS-38）[ ] favourites 同步 [ ] "在此仓库开新会话" = `attach(null, cwd)` + 注入 `claude\r` [ ] detail 的 400/404/500 `{error}` 显式路径

#### T-iOS-27 · Diff 查看器（只读）`[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/DiffScreen.swift` + VM 测试
- **Steps(测试先行)**: [ ] `DiffResult{files,staged,truncated}` 渲染、truncated 提示 [ ] staged/unstaged 切换 [ ] path 非法 404 → 友好错误

#### T-iOS-28 · 会话缩略图（offscreen SwiftTerm）`[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/SessionThumbnail.swift` + 快照测试
- **Steps(测试先行)**: [ ] `GET /live-sessions/:id/preview`（`{id,cols,rows,data}`，24KB tail）→ 离屏 TerminalView feed → 快照图 [ ] 列表滚动不掉帧（离屏渲染限并发）[ ] 404 → 占位图

#### T-iOS-29 · 杂项闭环：new-in-cwd + 退出会话清理 `[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `TerminalScreen` 的小增量 + Tests（**不碰 `SessionListScreen`**——列表侧入口/行项变更移交 T-iOS-23，该文件 W7 内单一 owner）
- **Depends**: T-iOS-23（列表侧入口） · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] "在当前会话 cwd 开新会话"（`attach(null, cwd)`）[ ] exited 会话点开 → 回放 + exit 横幅 + "开新会话"动作（src/session/manager.ts:145-153 语义）

#### T-iOS-30 · P1 验收 + 安全复核 `[ ]` · ~1 pd（report-only）
- **Steps**: [ ] F-iOS-14/15（§9）真机走查 [ ] 安全：APNs payload 审计、token 生命周期、deep link fuzz、通知在锁屏的预览泄露面（默认隐藏内容验证）、**锁屏 Allow 动作必须 `.authenticationRequired`（真机验：锁屏 Allow 弹 Face ID/通行码；Deny 无需解锁）**。

**P1 合计 ≈ 17.5 人天**（含新增 T-iOS-37 0.25 + T-iOS-38 0.5；T-iOS-21/26 相应减负）。

---

### P2 — 打磨 · 合计 ~8 人天

> P2 任务此处只给**分派必需元数据**（Wave/Owns/Depends/Accept）；完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写（不改 §3 接口）。**未扩写前不得按下述描述直接分派**（§4 TDD 强制与 §6 Owns 铁律同样适用）。

- **T-iOS-31** · 语音 PTT + 确认（端口匹配器 / 1.5s 撤销 / epoch 防误发）`[ ]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Tests（epoch 防误发若需 SessionCore 新接口 → 经 T-iOS-6 owner 回 SessionCore 加，不直改）· **Depends**: T-iOS-6/11；**前置**：Info.plist 加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`（§5.2 注）· **Accept**: VM 测试 + 真机口述→确认→注入 input。
- **T-iOS-32** · Worktree 创建（`POST /projects/worktree`，G）+ `claude --resume <id>` 历史（`GET /sessions`）`[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + Tests（APIClient builders 经 T-iOS-38 owner 模式回 APIClient 加）· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次。
- **T-iOS-33** · 终端内搜索 `[ ]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮。
- **T-iOS-34** · 主题 + Dynamic Type `[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量（与同波任务文件不相交，开工时列明文件清单）· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版。
- **T-iOS-35** · web `?join=` 互通（分享 QR 双向）`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量（`?join=` 解析）· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话。
- **T-iOS-36** · P2 验收 `[ ]` ~1 pd（report-only）。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-31–35。

**总计：P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**

---

## 8. 分派批次与模型/隔离分配（多 agent 用）

| 批次 | 可同时开工 | 说明 |
|------|-----------|------|
| 第 1 批 | T-iOS-1 | 串行，工程骨架 |
| 第 2 批 | T-iOS-2 ∥ T-iOS-3 | spike 与契约并行（都只依赖骨架） |
| 第 3 批 | T-iOS-4 | 替身（快，可并入第 4 批首个 agent） |
| 第 4 批 | **T-iOS-5,6,7,8** | W1 四叶子全并行 |
| 第 5 批 | T-iOS-9 ∥ T-iOS-10 | 接口已冻结，可并行编码，集成时汇合 |
| 第 6 批 | **T-iOS-11,12,13,14** ∥ T-iOS-16 ∥ T-iOS-17 | W3 UI 四路并行（各对替身开发）；T-iOS-16 只依赖 W2（提前把协议防漂移闸门变绿）、T-iOS-17 零依赖 |
| 第 7 批 | T-iOS-15 | 汇合接线（依赖 T-iOS-11–14） |
| 第 8 批 | T-iOS-18 ∥ T-iOS-19 | 验收 report-only |
| P1 批 | T-iOS-20 ∥ T-iOS-37 ∥ T-iOS-38 → 其余 W7 并行（仅 21 等 20/38、23 等 37、26 等 38） | W6 与 W7 不整体串行（见 P1 波次注） |

> 单批 ~3–5 agent 甜区；worktree 前提：**W0（T-iOS-1…4）先 commit**，再派 worktree 隔离的并行 builder。

| 任务 | 类型 | 建议模型 | 隔离 | 理由 |
|------|------|----------|------|------|
| T-iOS-1 脚手架 | builder | sonnet | — | XcodeGen/Swift6 配置排错；串行 |
| T-iOS-2 双 spike | builder | sonnet | — | 平台事实一锤定音；需真机人工配合 |
| T-iOS-3 WireProtocol | builder | sonnet | — | 契约精度要紧；全员依赖根 |
| T-iOS-4 替身 | builder | haiku | — | 小而机械 |
| T-iOS-5 Reconnect+Ping | builder | sonnet | worktree | 纯状态机，时序推理 |
| T-iOS-6 Gate+Digest | builder | sonnet | worktree | epoch 语义是防误批关键 |
| T-iOS-7 HostRegistry | builder | haiku | worktree | 薄封装 + 明确规格 |
| T-iOS-8 APIClient | builder | sonnet | worktree | Origin iff-G 是安全语义 |
| T-iOS-9 WSTransport | builder | sonnet | worktree | delegate/re-arm 细节多 |
| T-iOS-10 SessionEngine | builder | **opus** | worktree | 并发/生命周期/重连时序最硬的模块 |
| T-iOS-11 Terminal+KeyBar | builder | sonnet | worktree | UIKit 桥接 + 字节表 |
| T-iOS-12 Pairing | builder | haiku | worktree | VM 映射为主 |
| T-iOS-13 SessionList | builder | sonnet | worktree | 轮询/TTL/乐观更新 |
| T-iOS-14 Gate UI | builder | sonnet | worktree | 三选一（acceptEdits/default/reject，无 allowAutoMode 门） |
| T-iOS-15 接线 | builder | sonnet | — | 汇合点，串行 |
| T-iOS-16 集成 CI | builder | sonnet | — | 真服务器时序断言 + 覆盖率门接线 |
| T-iOS-17 ntfy 验证 | builder | haiku | — | 验证既有桥 + 文档，零新代码 |
| T-iOS-18/19 验收 | **reviewer** | sonnet | — | 只读报告；修复回流 owner |
| T-iOS-20 APNs(server) | builder | **opus** | — | server 触点 + 密钥/token 语义 |
| T-iOS-37 lastOutputAt(server) | builder | haiku | — | server 触点：一字段 + 一行映射 + 测试（TS 任务） |
| T-iOS-38 APIClient P1 契约 | builder | sonnet | — | W7 首发；APIClient 单一 owner，其余任务依赖它 |
| T-iOS-21–29 | builder | sonnet(28 可 haiku) | worktree | 常规并行 |
| T-iOS-30/36 验收 | reviewer | sonnet | — | report-only |
| T-iOS-31–35（P2） | builder | sonnet(33/34/35 可 haiku) | worktree | 按 P2 节元数据分派；未扩写 RED 清单前不派 |

---

## 9. 测试与验收

### TDD 工作流

每任务：**RED**（照 Steps(测试) 先写失败测试）→ **GREEN**（最小实现过测）→ **REFACTOR**（对照 §4 清单）。测试命名讲行为（`test("未知 UUID attach 后采用服务器新发的 id")`），AAA 结构。

### 覆盖率门（≥ 80%，只量 4 个包）

```bash
for p in WireProtocol SessionCore HostRegistry APIClient; do
  swift test --package-path ios/Packages/$p --enable-code-coverage
  BIN="$(swift build --package-path ios/Packages/$p --show-bin-path)/${p}PackageTests.xctest/Contents/MacOS/${p}PackageTests"
  PROF="$(swift test --package-path ios/Packages/$p --show-codecov-path | xargs dirname)/default.profdata"
  # 只量生产代码:排除测试目标自身、TestSupport 替身、SwiftPM 生成的 runner shim
  # (不排除的话 Tests/** 近 100% 覆盖会虚抬 TOTAL);jq -e 低于阈值退出非零 → CI 红
  xcrun llvm-cov export -summary-only "$BIN" -instr-profile "$PROF" \
    -ignore-filename-regex '(Tests|TestSupport|\.build)/' \
    | jq -e '.data[0].totals.lines.percent >= 80'
done   # 每包 line coverage ≥ 80%,CI 强制(接线与红/绿演示归 T-iOS-16)
```

> **KeychainHostStore 计法**：`swift test` 覆盖的是经 `SecItemShim` 注入 fake 的 store 逻辑（unsigned 测试二进制拿不到 data-protection keychain，-34018）；真 Keychain 路径 + `kSecAttrAccessible` 断言在 xcodebuild 模拟器测试（签名宿主）里跑，不计入本门。

### 集成测试（真 Node 服务器）

CI macOS runner：`npm ci` → `PORT=<ephemeral> ALLOWED_ORIGINS=… npm start` → `swift test --package-path ios/IntegrationTests`（用例见 T-iOS-16）。这层持续看护"客户端复刻协议"与服务器实现之间的契约漂移，也是 Origin spike 的常驻化。

### 设备矩阵

| 层 | 环境 |
|---|---|
| 单元/包 | macOS（swift test，无模拟器） |
| App/XCUITest | iPhone 16 模拟器（iOS 26 SDK）+ iOS 17 最低目标模拟器各一轮 |
| 真机必测项 | 键盘/IME/key-bar、QR 扫码、haptics、Local Network 弹窗、ntfy/APNs 通知、锁屏动作（1 台实机，iOS 18+） |

XCUITest 只保一条 happy path：配对→attach→输入→gate approve（脆弱面最小化）。

### 验收演示脚本（F-style，对齐 ARCHITECTURE §8）

- **F-iOS-1** iPhone 扫 Mac web UI 的 QR → 配对探针通过 → host 入列。
- **F-iOS-2** 会话列表显示 Mac 上运行中的会话：状态点、telemetry 芯片、cwd。
- **F-iOS-3** 点开会话 → 全量 scrollback 回放（预灌 >1 MiB 输出 + ESC/C0 密集输出，验证 16 MiB 帧上限）→ 实时流续上。
- **F-iOS-4** 打字、跑 `vim`/`top`、旋转屏幕 → resize 生效、TUI 不错位。
- **F-iOS-5** key-bar：Esc/Shift+Tab/方向/Ctrl-C 生效；中文 IME 输入不乱码不重复。
- **F-iOS-6** Claude 触发 tool gate → 手机横幅 + 震动 → Approve/Reject 生效；plan gate → 三选一 sheet；**Approve+Auto 发送 `mode=acceptEdits`（与 web 端一致，public/tabs.ts:345-347）**。
- **F-iOS-7** 杀掉 App → 重开 → 自动回到上次会话，回放无缺。
- **F-iOS-8** 切后台 5 分钟 → 回前台 → "reconnecting…" → 自动重连 + 全屏尺寸夺回（latest-writer-wins）。
- **F-iOS-9** 手机与浏览器同 attach 一个会话（mirror）：双方都能打字、都见输出，互不踢。
- **F-iOS-10** 离开期间 Claude 干了活 → 重连后顶部 away-digest 摘要正确。
- **F-iOS-11** held gate 时手机收到 ntfy 通知（P0）；P1 换 APNs 后：锁屏长按 → Allow → **Face ID/通行码确认（`.authenticationRequired`）** → 不开 App，Mac 侧放行（Face ID 设备上仍是两次手势 + 一瞥；Deny 无需解锁）。
- **F-iOS-12** 负路径:服务器未白名单该 Origin → 配对页出现可操作话术（含 ALLOWED_ORIGINS 提示）；Local Network 被拒 → 引导话术。
- **F-iOS-13** 列表 swipe-to-kill → 会话消失，Mac 侧 PTY 确认被杀；exited 会话点开见末屏 + exit 横幅。
- **F-iOS-14**（P1）push tap 深链直达 gated 会话。
- **F-iOS-15**（P1）多会话切换：unread 点、OSC 标题、切换回放 <1s 观感。

---

## 10. 风险与开放问题

| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| URLSessionWebSocketTask 自定义 Origin 实为 MED 置信度 | 高 | T-iOS-2 Day-1 一锤定音；失败 → Starscream 备胎（仅此一处允许第三方依赖，需 orchestrator 拍板） |
| SwiftTerm 键盘/IME/选择是历史雷区 | 中 | Day-1 真机 smoke + T-iOS-11 不自加 keydown 拦截 + T-iOS-18 真机验收专项 |
| 回放单帧撞 `maximumMessageSize` | 中(已缓解——默认配置) | 16 MiB 常量(≥6×默认 SCROLLBACK_BYTES,JSON 控制字节转义最坏 6×) + spike ③ 对抗复现 + T-iOS-16 常驻回归 + 超限→不可重试 `.replayTooLarge` 显式错误态(绝不无限重连)。**残余**:主机把 SCROLLBACK_BYTES 调到 >~2.7 MB 或极端转义密集回放仍可超限(客户端运行时无法得知服务器配置)。**彻底消除**需服务器把回放 snapshot 分块成有界 `output` 帧(≤256 KiB,按 ring 既有 append-chunk 边界切,不割 ANSI/UTF-8,M2 语义)——另立 server 任务,落地后本行方可标"已消" |
| iOS 后台 socket 必死 → 后台会话信号有轮询延迟 | 中 | 设计即前台单 WS；P1 APNs 补上"主机找手机"；评审已接受 |
| ATS 对裸 IP/CIDR 的语义在 iOS 17+ 有过变更 | 中 | §5.2 三段 CIDR + `NSAllowsLocalNetworking`；真机矩阵含 iOS 18 弹窗 bug 项 |
| MagicDNS FQDN 触发完整 ATS | 中 | 话术引导用 100.x IP 或 `tailscale serve`(wss) |
| 协议在 TS 与 Swift 双实现,会漂移 | 中 | T-iOS-3 移植服务器测试向量 + T-iOS-16 真服务器 CI 常驻 |
| APNs 需付费开发者账号 + `.p8` 运维 | 中 | P0 用 ntfy 桥过渡；P1 前用户拍板账号 |
| ntfy topic 泄露 = 通知可被旁观 | 低 | 随机 topic + payload 最小化（T-iOS-17 安全注）|
| epoch 防误批依赖客户端自律（服务器无 epoch 概念） | 中 | GateState epoch 单测 + 验收 F-iOS-6;长期可提案服务器带 gate id（另立 server 任务，不在本计划）|
| 单 WS 设计与多会话切换器（P1）的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |

**待你拍板的开放项**：
1. Bundle id / 产品名 / 图标（App 叫什么？`webterminal://` scheme 是否可用/要改名？）
2. Apple 付费开发者账号（$99/年）何时开——决定 P1 APNs 与 TestFlight 分发起点；P0 期间接受自签 sideload？
3. 最低系统版本：iOS 17（可分发下限）还是 18/26（个人工具，availability 噪音最小）？
4. ~~P0 的 ntfy 桥要不要做成默认关闭~~ **已解决**：桥已随 `npm run setup-hooks` 出货且默认关闭（`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC` 未设即完全无副作用）——无需新做，T-iOS-17 只验证/写文档。
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**：推荐 `tailscale serve`（wss）为标准部署话术（配对 UI/README 采用；绕开全部 ATS 例外与明文嗅探面，见 §5.4）。

---

## 11. 与现有文档的关系

- 本文只新增 `ios/`，加 §0.3 声明的服务器触点（**P0 零触点**——ntfy 桥复用既有 setup-hooks 实现；P1 两处：APNs sender + token 端点（T-iOS-20）、`LiveSessionInfo.lastOutputAt` 字段（T-iOS-37））。**除触点外不改 `src/` 与 `public/`**。
- 实施中发现的 server 侧缺陷：修复归属对应模块（route/session/protocol 文件的 owner 任务），按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server；T-iOS-20 作为 server 任务遵循根仓库工作流。
- 冲突裁决：**ARCHITECTURE 管 how、TECH_DOC 管 why/scope**；本文是它们之上的"原生客户端"新层，不改协议/会话模型，只消费之。§0/§3 引用的 `file:line` 线协议事实以**代码现状**为准——若代码演进导致引用失效，更新本文并记 LOG。
- 与 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md) 平行：desktop 是"内嵌服务器的壳"，iOS 是"纯远端客户端"——二者都不改动核心，互不依赖。
- 与 relay 计划族（[PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)）的边界：v1 明确不接 relay（§5.5）；relay 可部署后，接入方案另立计划文档，不回改本文任务。
