Files
web-terminal/docs/PLAN_IOS_CLIENT.md
Yaojia Wang cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00

92 KiB
Raw Blame History

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

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


0. 目标与范围

做什么

一个 iPhone 原生 AppiOS 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 primitivetool gate 的 Approve/Reject 横幅 + plan gate 三选一Approve+Auto / Approve+Review / Keep Planninggate 到达触发 haptics。
  • "离开期间发生了什么"digest:重连后终端顶部渲染 away-digest来源 GET /live-sessions/:id/events)。
  • 主机找得到手机P0 复用既有 ntfy 桥(npm run setup-hooks 已内置,WEBTERM_NTFY_URL+WEBTERM_NTFY_TOPIC 设置即装零新代码NEEDS-INPUT/DONEP1 换 APNs + 锁屏 Allow/Denynotification action → Face ID/通行码确认(.authenticationRequiredPOST /hook/decision,仍不启动 App UI
  • 移动键位补全native inputAccessoryView key-barEsc / Shift+Tab / 方向 / Ctrl-C…字节表逐字节复刻 public/keybar.ts+ 硬件键盘 UIKeyCommand
  • 配对即用:扫 web UI 的 QRqr.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),且已审计的 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 pushP1 之后才有条件)。
  • 后台常驻 WSiOS 切后台数秒即 suspend做不到也不装能做到设计基石就是 foreground-reattach。
  • 前端/服务器重构public/src/ 一行不改(触点见 §0.3)。

已被验证掉的大风险(关键复用点,引现有代码为证)

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

// 会话/连接解耦: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-238WEBTERM_NTFY_URL+WEBTERM_NTFY_TOPIC 设置即装NEEDS-INPUT=high、DONE=lowenv 已被 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 维护 lastOutputAtsrc/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 AppSwiftUI─────────────────────────────┐
│                                                                                │
│  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 actoriOS 切后台数秒内 suspend、socket 必死Apple forums 716118per-session 常驻连接是在跟平台打一场必输的仗,还会制造大量"看着连着其实死了"的僵尸状态。前台会话独占唯一活 WS切会话 = detach + attach服务器回放 ring-buffer切换成本 ≈ 0后台会话的"有没有新动静"靠 /live-sessions 轮询P0→ APNsP1。这与服务器的 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-171JSON 会把控制字节 \uXXXX 转义膨胀 16×src/protocol.ts:186最坏 ≈ 6 × SCROLLBACK_BYTES默认 2 MiB——必须设 Tunables.maxWSMessageBytes = 16 MiB(≥ 6×默认 + 帧包络);即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),超限时 receive() 以 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"T-iOS-2 spike 实测勘误——原文误标 ENOBUFSDarwin ENOBUFS=55归类以 40 为主、55 兜底)失败——必须归类为不可重试的 connection(.failed(.replayTooLarge)) 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环(否则确定性无限重试);receive() 一次只交付一条消息,必须循环 re-arm,忘了就静默断流;没有自动 ping25 s 定时 sendPingPingScheduler+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。

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

为什么 v1 只做 LAN + Tailscale、不碰 relayTailscale iOS App 是系统级 VPNNetwork 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 + telemetrysrc/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 generateWebTerm.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. 集成CImacOS 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

// 伪代码 —— 不可变、显式错误处理(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 — 连接核心

// 伪代码 —— 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 §1URLSessionWebSocketTask 无自动 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 标题净化上限
maxWSMessageBytes 16 MiB16 * 1024 * 1024 ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。耦合警示(写进 doc comment:回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数16×控制字节→\uXXXXsrc/protocol.ts:186SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 .replayTooLarge§3.2 / T-iOS-9/10

3.3 HostRegistry

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

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<Host, PairingError>
// 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返
//(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。
// 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。

3.5 App 胶水(不冻结,示意)

TerminalViewModel@MainActor @Observable):持有 SessionEnginefor await event in engine.eventsoutput feed() 给 SwiftTerm、把 connection/exited 转成 UI 状态;实现 TerminalViewDelegate.sendengine.send(.input(…))sizeChangedengine.send(.resize(…))GateViewModel(独立 VMT-iOS-14消费同一 events 流的 .gate/.digestT-iOS-15 接线。Swift 6 strict concurrency 编译期强制 socket→main 的 hop。scenePhase == .activeengine.notifyForegrounded(dims:)(重连 + 补发 resizelatest-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 类内。
  • 文件 200400 行典型,硬上限 800;多小文件、按 feature 组织。
  • 函数 < 50 行,单一职责。
  • 早返回优先,嵌套 > 4 层禁止。
  • 无魔法数字:阈值/延迟/上限一律具名常量(Tunables.pingIntervalTunables.maxWSMessageBytes = 8 * 1024 * 1024…)。
  • 无硬编码配置/密钥:用户可见配置进 Settings必需配置启动即校验、fail fast。
  • 系统边界全验证把服务器当不可信输入源——每一帧过 MessageCodec/Validation 白名单,非法 → 丢弃(不 crash、不猜用户输入URL、扫码结果同样先验证。
  • 错误处理全面显式UI 给用户友好话术PairingError 分类学、ReconnectBanner内部日志留细节绝不静默吞错。
  • Conventional commitsfeat:/fix:/test:…),提交边界对齐任务。
  • 每任务完成即 code reviewCRITICAL/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 + 端口派生(不是 bindHosthttp(s)://localhost:<port>127.0.0.1、各非内网卡 IP再并入 ALLOWED_ORIGINS envsrc/config.ts:187-226。因此

  • App 必须在 (a) WS 升级(否则 401src/server.ts:646-651(b) 所有 G 类变更 HTTP(否则 403src/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/portsrc/http/origin.ts:31-51https 默认端口写不写 :443 均匹配WHATWG URL 把默认端口规范化为空串双向对称。PairingError 话术只引导"加与拨号 URL 一致的 ALLOWED_ORIGINS",不引入端口迷信。

5.2 ATS 与 Local NetworkInfo.plist 精确键,只开最小口子)

# 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 servehttps/wss最优
  • Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 PairingError.localNetworkDenied 并引导去 设置→隐私→本地网络iOS 18 有需重启的已知 bug话术里写明

5.3 凭据与本地存储

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

5.4 继承的威胁模型TECH_DOC §7

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

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

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

5.5 relay / E2E显式推迟为什么不现在做

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


6. 多 Agent 并行规则与波次

并行三条铁律(同 PLAN.md §0违反会互相踩踏

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

subagent 不能问用户、不能互相通信:遇到本文+ARCHITECTURE+TECH_DOC 未定义的歧义 → 停下返回 [!] BLOCKED绝不猜。单批并行控制在 ~35 个 agent。验收任务 report-onlyG4findings 标 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/#expectXCTest 仅 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.ymlios/.gitignore、5 个 Package.swift 空壳4 个 gated 包 + ios/IntegrationTestsTestSupport 的 manifest 归 T-iOS-4 的 ** globios/App/WebTerm/WebTermApp.swift空窗、CI workflow 骨架(.github/workflows/ios.yml
  • Depends: 无 · Parallel-safe: 无(必须最先)
  • Steps:
    • project.ymlApp targetiOS 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 产物 gitignoreCI 骨架跑 swift test0 测试也过)
  • Accept: xcodegen generate && xcodebuild … build 通过;空 App 在模拟器启动
  • 安全注: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。

T-iOS-2 · Day-1 双 spike评审强制[ ] · ~1 pd

  • Wave/阶段: W0 / P0 · Owns: ios/IntegrationTests/OriginSpikeTests.swiftios/App/WebTerm/Screens/SpikeTerminalScreen.swift临时文件W4 删)
  • Depends: T-iOS-1 · Parallel-safe: T-iOS-3
  • Steps(测试先行——spike 本身就是测试):
    • OriginSpikeTests(对本仓库真服务器 npm start):① 无 Origin 的 WS 升级收 401src/server.ts:646-651Origin: http://127.0.0.1:<port> 精确匹配 → 升级成功、attach(null) 收到 attachedmaximumMessageSize 默认 1 MiB 时灌 >1 MiB 输出再 reattach → 复现失败;设 Tunables.maxWSMessageBytes16 MiB→ 回放成功;追加对抗用例:预灌 ESC/C0 控制字节密集输出JSON \uXXXX 转义膨胀 ~6×再 reattach → 仍成功 ④ 端口不匹配的 OriginOrigin: http://127.0.0.1:9999)→ 401src/http/origin.ts:47-51 端口精确比对;注意默认端口如 :443 会被 new URL() 双向规范化、不是失配案例)
    • 真机 smoke人工清单结果记录进返回条目SwiftTerm 键盘弹出/first-responder、中文 IME 组合输入、inputAccessoryView 原型上 Esc/Ctrl-C 可发、文本选择不崩
  • Accept: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 withKnownIssue 记录);真机清单逐项有结论
  • 安全注: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"MED 置信度)的一锤定音;若失败 → [!] BLOCKEDorchestrator 决策切 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.swiftHostEndpointTests.swiftServerVectorTests.swift:
    • 5 种 ClientMessage encode 后的 JSON 与服务器 parseClientMessage 接受的形状逐键一致(attach 必含 sessionIdnull 也要显式src/protocol.ts:132-134
    • decodeServer 5 种合法帧解出对应 caseattached 的 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-79mode 由 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}.swiftTests/…/{ReconnectMachineTests,PingSchedulerTests}.swift
  • Depends: T-iOS-3、T-iOS-4FakeClock · Parallel-safe: T-iOS-6/7/8
  • Steps(测试先行, RED):
    • 断线序列 → 重试延迟 1s,2s,4s,8s,16s,30s,30s封顶
    • connected 输入 → backoff 归零,下次断线从 1s 重来
    • foregrounded/userRetry → 立即 connectNow,不等定时器
    • reduce 是纯函数同输入同输出原值不被改Equatable 断言旧快照)
    • PingScheduler25s 触发 ping1 次 pong 丢失容忍、连续 2 次 → 发出 disconnected 信号FakeClock 推进驱动,测试 0 真实等待
  • Steps(实现, GREEN): [ ] §3.2 签名 [ ] 常量一律读 TunablesWireProtocolT-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 reduceevents 里 3 tool + 1 waiting + done → {toolRuns:3, waitingCount:1, sawDone:true}
    • since 过滤:早于离开时刻的事件不计入
    • limit 截断 recent空 events → 全零 digestUI 可据此不渲染)
  • 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 SecItemShimadd/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 模拟器测试(签名宿主 AppT-iOS-15/16 管线)
  • Accept: swift test --package-path ios/Packages/HostRegistry 全绿(覆盖率计法见 §9KeychainHostStore 以 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 headerkillSession/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(Host);探针成功路径里 attach(null) 后必发 kill不留孤儿会话
    • 超时FakeClock.timeout
  • Steps(实现, GREEN): [ ] §3.4 签名 [ ] 服务器约束写进 doc comment按端点精确hookDecision body ≤ 4 KBsrc/server.ts:503、≤10 次/分/IPsrc/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.swiftTests/…/URLSessionTermTransportTests.swift
  • Depends: T-iOS-2spike 结论、T-iOS-3 · Parallel-safe: T-iOS-10接口并行
  • Steps(测试先行, RED) — 对 in-process 本地 WS echo测试内起 NWListener 或复用 IntegrationTests 服务器):
    • 升级请求含 Origin header 且等于 endpoint.originHeader
    • maximumMessageSize == Tunables.maxWSMessageBytes16 MiB——直接断言 task 配置
    • receive 循环持续 re-arm连发 100 帧全部到达、顺序不乱
    • 服务器关闭close frame→ frames stream finish错误 → stream throw两种可区分
    • receive 失败 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"——T-iOS-2 spike 实测;原文 ENOBUFS 为笔误55(ENOBUFS) 作兜底同判——超 maximumMessageSize 在 iOS 上不是 1009 干净关闭)→ stream throw 类型化 .replayTooLarge 错误(供 engine 识别为不可重试)
    • close() 后 send → 显式错误,不 crash
    • 非文本binary帧 → 丢弃并继续收(服务器只发文本帧,但不信任它)
  • Steps(实现, GREEN): [ ] URLSessionWebSocketDelegatedidOpen/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}.swiftTests/…/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 时采用新 idsrc/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:) → 重连后补发 resizelatest-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 抛 .replayTooLargeconnection(.failed(.replayTooLarge)) 事件、停止重连(不可进 backoff 循环——否则确定性无限重试)
    • close() → transport.close 被调、events stream finish、无泄漏 task用 confirmation 断言)
  • Steps(实现, GREEN): [ ] §3.2 签名 [ ] attach 后立即补发一次 resize服务器 80×24 spawnsrc/server.ts:714-717
  • Accept: swift test --package-path ios/Packages/SessionCore 全绿SessionCore 包覆盖率 ≥ 80%
  • 安全注: 每帧过 MessageCodec.decodeServernil → 丢弃计数(日志),绝不 crash。

W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)

T-iOS-11 · TerminalScreen + KeyBar [ ] · ~1 pd

  • Wave/阶段: W3 / P0 · Owns: App/WebTerm/Screens/TerminalScreen.swiftComponents/{KeyBar,ReconnectBanner}.swiftViewModels/TerminalViewModel.swiftSessionCore/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.tsEsc=\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/
    • VMengine 发 outputfeed 调用在 MainActor编译期由 Swift 6 保证,测试断言转发次序)
    • VMconnection(.reconnecting) → banner 状态量;.connected → 隐藏
    • VMconnection(.failed(.replayTooLarge)) → 不再重试的错误态 + 可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
    • VMexited → 终端只读 + exit 提示状态
  • Steps(实现, GREEN): [ ] UIViewRepresentableSwiftTerm.TerminalViewdelegate sendengine.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 · PairingScreenQR + 手输 + 探针 UI[ ] · ~0.5 pd

  • Wave/阶段: W3 / P0 · Owns: App/WebTerm/Screens/PairingScreen.swiftViewModels/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在确认页展示):公网 hosthttp/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.swiftComponents/TelemetryChips.swiftViewModels/SessionListViewModel.swift
  • Depends: T-iOS-8接口 · Parallel-safe: T-iOS-11/12/14
  • Steps(测试先行, RED) — VM 对 FakeHTTPTransport:
    • 轮询节奏:前台每 Tunables.listPollInterval5 s§3.2.1)拉一次 /live-sessions;离开页面停止(无泄漏 timer
    • 状态点映射 5 态 + pending:true → ⚠ 徽标优先
    • telemetry stalenessat 距今 > Tunables.telemetryStaleTtlMs30 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}.swiftViewModels/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 → 三选一 sheetApprove+Auto / Approve+Review / Keep Planning
    • 三选一映射(镜像 public/tabs.ts:345-347 与 src/types.ts:84-86Approve+Auto→approve(mode:.acceptEdits)、Approve+Review→approve(mode:.default)、Keep Planning→reject无 allowAutoMode 门——web 端从不在 plan gate 上做此门控;acceptEdits 不受 SEC-M5 auto 降级影响(降级只打 raw autosrc/server.ts:765-766raw 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-1114 全部 · Parallel-safe: T-iOS-16/17
  • Steps: [ ] Pairing→List→Terminal 导航 + 依赖注入(真实现 wiringGateViewModelT-iOS-14接入 TerminalScreen[ ] scenePhase == .activeengine.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-1115/17
  • Steps(测试清单) — macOS runnernpm ci → 临时端口 npm startALLOWED_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 mirrorA、B 同 attachA inputB 收 output
    • 无 Origin → 401错 Origin → 401DELETE 无 Origin → 403G 守卫回归)
    • DELETE /live-sessions/:idkill→ 镜像客户端观察到 WS close不是 exit 帧——manager.killByIdws.close() 全部客户端再 killsrc/session/manager.ts:202-212sendIfOpen 只投给 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-onlyG4只报告不改码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-20payload 形状、T-iOS-23 依赖 T-iOS-37lastOutputAt 字段),其余 W7 任务T-iOS-22/24/25/27/28/29不等 W6T-iOS-38APIClient 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-525APNs payload 不含命令内容。

T-iOS-37 · server: LiveSessionInfo.lastOutputAt 字段 [ ] · ~0.25 pd

  • Wave: W6 · Owns: src/types.tsLiveSessionInfo 增量字段、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-8T-iOS-20仅 token 注册 builder 的端点形状——可接口先行并行编码,形状定稿时汇合) · Parallel-safe: T-iOS-20/22/23/24/25/27/28/29均不碰 APIClient 文件)
  • Steps(测试先行): [ ] token 注册 builderG带 Origin[ ] projects/detail/prefs buildersPUT 带 Origin与解码 [ ] doc comment 端点约束push subscribe body ≤ 8 KB、≤5 次/分/IPsrc/server.ts:73,461-466PUT /prefs ≤ 64 KBsrc/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 + 对应 TestsAPIClient 的 token 注册 builder 归 T-iOS-38
  • Depends: T-iOS-20payload 形状、T-iOS-38token 注册 builder · Parallel-safe: T-iOS-2229
  • Steps(测试先行): [ ] UNNotificationCategory Allow/Deny 注册形状——Allow 动作必须带 UNNotificationActionOptions.authenticationRequired(锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe。断言注册 category 的 Allow 选项含 .authenticationRequired,且两动作均不含 .foreground[ ] action handler从 payload 取 {sessionId, token}POST /hook/decision(带 Origin不启动 App UIFace ID 设备上"两次手势 + 一瞥"闭环)[ ] token 用后即弃不落盘 [ ] 决策失败token 过期 403→ 补一条本地通知提示进 App 处理
  • 安全注: Allow/Deny 由系统后台拉起主 App、送达 UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)——无 extension 参与(本工程没有 notification extension targetService 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-37lastOutputAt 字段) · Parallel-safe: T-iOS-21/22/24/25/26/27/28
  • Steps(测试先行): [ ] 单活 WS 不变:切会话 = close→open回放恢复 [ ] unread 判定:/live-sessions 快照的 lastOutputAtT-iOS-37 新增字段)> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm setTerminalTitle delegate 上浮到列表——标题是主机/攻击者可控输入,入列表前过净化器:截断 Tunables.titleMaxLength(256);剥 Unicode 双向覆写与零宽字符U+200B200F、U+202A202E、U+20662069——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 Testsprojects/detail/prefs 的 APIClient builders 归 T-iOS-38本任务只消费
  • Depends: T-iOS-38builders · Parallel-safe: T-iOS-21/22/23/24/25/27/28/29
  • Steps(测试先行): [ ] VM 消费 /projects/projects/detail?path=GET/PUT /prefsbuilder 与解码测试在 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 pdreport-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.5T-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 Testsepoch 防误发若需 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/worktreeG+ claude --resume <id> 历史(GET /sessions[ ] ~1.5 pd。Wave: W9 · Owns: App/WebTerm/Screens/WorktreeSheet.swift + TestsAPIClient 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 pdreport-onlyWave: W10 · Owns: 无源码 · Depends: T-iOS-3135。

总计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-1114
第 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 波次注)

单批 ~35 agent 甜区worktree 前提:W0T-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-2129 builder sonnet(28 可 haiku) worktree 常规并行
T-iOS-30/36 验收 reviewer sonnet report-only
T-iOS-3135P2 builder sonnet(33/34/35 可 haiku) worktree 按 P2 节元数据分派;未扩写 RED 清单前不派

9. 测试与验收

TDD 工作流

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

覆盖率门(≥ 80%,只量 4 个包)

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 runnernpm ciPORT=<ephemeral> ALLOWED_ORIGINS=… npm startswift test --package-path ios/IntegrationTests(用例见 T-iOS-16。这层持续看护"客户端复刻协议"与服务器实现之间的契约漂移,也是 Origin spike 的常驻化。

设备矩阵

环境
单元/包 macOSswift 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-barEsc/Shift+Tab/方向/Ctrl-C 生效;中文 IME 输入不乱码不重复。
  • F-iOS-6 Claude 触发 tool gate → 手机横幅 + 震动 → Approve/Reject 生效plan gate → 三选一 sheetApprove+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 通知P0P1 换 APNs 后:锁屏长按 → Allow → Face ID/通行码确认(.authenticationRequired → 不开 AppMac 侧放行Face ID 设备上仍是两次手势 + 一瞥Deny 无需解锁)。
  • F-iOS-12 负路径:服务器未白名单该 Origin → 配对页出现可操作话术(含 ALLOWED_ORIGINS 提示Local Network 被拒 → 引导话术。
  • F-iOS-13 列表 swipe-to-kill → 会话消失Mac 侧 PTY 确认被杀exited 会话点开见末屏 + exit 横幅。
  • F-iOS-14P1push tap 深链直达 gated 会话。
  • F-iOS-15P1多会话切换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 必死 → 后台会话信号有轮询延迟 设计即前台单 WSP1 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 servewss为标准部署话术配对 UI/README 采用;绕开全部 ATS 例外与明文嗅探面,见 §5.4)。

11. 与现有文档的关系

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