用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/Jasonzhangf/zterm --skill zterm-mobile-dev命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
| name | zterm-mobile-dev |
| description | 审计、开发和验证 zterm Android 客户端;按当前架构、最新目标和 owner 选择门禁,区分源码、设备与 OTA 证据。 |
../../..。App -> slot -> component/controller -> business owner;检查状态归属和实际 I/O,不能用 slot wrapper 的无 import 测试证明下游纯投影。每次开发前必须按顺序阅读:
1. ~/.codex/AGENTS.md → 全局入口、硬护栏
2. ~/.codex/USER.md → 用户偏好(称呼 Jason)
3. coding-principals/SKILL.md → 开发方法论
4. android/docs/spec.md → 项目范围与验收
5. android/docs/architecture.md → 模块边界与数据流
android/docs/audits/2026-07-02-architecture-boundary-remediation.md → owner 边界
android/docs/decisions/2026-09-05-runtime-memory-truth.md → 当前实现与最新目标
android/memory/index.md → 相关最新条目与 source_refs
6. android/docs/decisions/0001-cross-platform-layout-profile.md → 跨尺寸布局 / Mac 共享壳决策
7. android/docs/decisions/2026-04-23-terminal-head-buffer-render-truth.md → terminal head / sparse buffer / render / UI 真源
8. android/docs/dev-workflow.md → 执行门禁与验证
9. android/task.md → 当前任务板
10. android/docs/ui-slices.md → 页面切片与 ownership
11. 本 SKILL.md → 项目约束、可复用门禁
../wterm,需要改底层时去 fork repo 改mac/、win/ 下其他客户端骨架../wtermzterm 命名,不再沿用 wterm-mobile-* 旧命名spec.md:产品范围与验收architecture.md:模块边界、数据流、ownershipdocs/decisions/0001-cross-platform-layout-profile.md:跨尺寸布局 / Mac 共享壳唯一决策dev-workflow.md:执行顺序、验证门禁、证据要求ui-slices.md:页面级切片与文件 ownershiptask.md:当前任务状态CACHE.md:本轮短期上下文MEMORY.md:长期可复用经验evidence/:截图、日志、APK、真机证据
android/evidence/ 是本地证据仓,默认不进 Git 主线;Git 中只保留目录说明文件desktop.remote_window_stream 的视频主链尚未完成时,不为中间态 catalog / overlay shell / debug 诊断改动反复构建 APK;先完成真实远程视频(ScreenCaptureKit/WebRTC frame stream 可见)并通过对应 gates,再构建 APK 给 Jason 测。除非 Jason 明确要求止血包或升级恢复包,否则不要在远程视频完成前编包。pnpm --dir android run build:android。adb install -r 或应用内 OTA;禁止用 adb uninstall、adb shell pm clear、删除应用数据目录或任何等价操作来解决版本覆盖/降级/测试问题。只有 Jason 明确授权且已完成配置导出或可验证备份后,才允许执行不可逆的数据清理。zterm-config.json,并保存导出文件路径、时间和 SHA-256;没有导出/备份证据就不能卸载、清数据或覆盖测试设备。测试只需要新数据时,使用独立设备、独立 Android 用户或 Jason 明确批准的隔离测试环境。package:dataDir、firstInstallTime/lastUpdateTime 和关键配置仍可读取;若配置缺失,立即停止后续安装/发布动作并报告,不得静默重置、重新初始化或把空配置当作成功。.build-meta.json 是持久化版本真源,禁止手工编辑、跳号、回退或为了绕过 Android 版本检查抬高 buildNumber。版本分配只能由 scripts/bump-build-version.mjs 完成;构建前后记录 buildNumber、APK 内 versionName/versionCode、manifest 和 SHA-256,发现不一致必须停止。scp、覆盖 latest.json、发布 stable channel 或重启生产服务。android/update-dist/latest.json、android/update-dist/zterm-<version>.apk、android/update-dist/zterm-latest-debug.apk、~/.zterm/updates/latest.json、~/.zterm/updates/zterm-<version>.apk sha/version 对齐。versionName、versionCode、APK 路径和 sha256;不能只说测试通过。0.1.3.N,回退版 0.1.3.N.1,下一正常版 0.1.3.N+1;APK 内必须同时满足 versionCode(N) < versionCode(N.1) < versionCode(N+1)。禁止用 bit 30、固定高位或任何会让后续正常版永久低于回退版的 namespace。发布前必须用 apkanalyzer 读取实际 APK 的 versionName/,并在不带 的真实设备上验证 normal N -> rollback N.1 -> normal N+1 可依次覆盖安装;manifest 文件名或 JSON 声明不能代替 APK 内 manifest 证据。android/docs/resource-registry.json、android/docs/module-registry.json、android/docs/edge-registry.json,再同步 android/docs/function-map.md、android/docs/wiki/mainline-call-map.json、android/docs/wiki/mainline-source.md,最后改 runtime。docs/modules/project-modules.md 是人读模块图;module-registry.json 是模块真源;edge-registry.json 是唯一允许跨模块资源边。找不到唯一 module_id / edge_id 时,不准直接补 runtime patch。client.daemon_connection:同一 daemon target 一个 client physical connection;tmux session 是 client.terminal_channel_mux 里的 channel,不是 socket owner;heartbeat 是 target-level,不是 session-level。createClientDaemonConnection() 只能在 transport owner 层创建:session-context-infra-facade-runtime.ts 负责共享 read/send owner,session-context-transport-orchestration-runtime.ts 只允许创建带 openSessionTargetTransport 的 mux open owner。public facade / interaction / lifecycle / message assemblies 只能消费已注入的 daemonConnection,禁止各自 new wrapper 或保留 daemonConnection || raw socket 双路径。queueSessionTransportOpenIntentRuntime() 只允许走 mux opener;缺少 openSessionMuxChannelByIntent 必须显式失败并删除 pending intent,禁止回退到 ensureControlTransportForSessionOpen 或其他 legacy session-ticket socket。getSessionTransportResource() 只要 session 已有 mux channel,就不得用 runtime.activeSocket 冒充有效连接;channel 未 ready 时资源 socket 必须保持空,等待同一 physical transport 上的 mux-ready。daemon.connection_gateway / daemon.transport_subscriber,不得保存 client active tab、foreground/background、viewport、renderer follow/reading、UI projection truth。relay.peer_lease 和 resource.transport_target;不得保存 terminal channel、transport subscriber、tmux、mirror、active tab、UI truth。pnpm --dir android run test:feature-registry -- --reporter dot,其中包含 src/lib/module-registry-truth.test.ts 和 src/lib/edge-registry-truth.test.ts。android/docs/audits/2026-09-05-file-browser-session-port.md 跑跨 rerender 回归。sendBoundedFileUploadChunks 使用 cumulative ACK 的固定窗口推进,禁止 UI stop-and-wait、无界 burst 或 caller 自定义窗口。下载 native bridge 只能由 writeFileTransferChunkBatches 聚合写入,禁止恢复逐 wire chunk bridge 调用。android/contracts/file-transfer-throughput.json 是上传窗口和 native batch 上限的唯一机器真源。TypeScript 直接导入,Android Gradle 从同一 JSON 生成 BuildConfig;禁止 TS/Java 分别维护数字。pnpm --dir android run test:file-transfer:throughput 必须同时跑 TS/module loopback 与 StorageFileWriteLogicTest,并真实接入 prebuild 和 CI。StoragePermission.writeFileChunks({ path, chunks, append }) 并 stat 校验落盘字节后,才允许跑下载持久化测速;源码/Gradle 单测绿但设备返回 UNIMPLEMENTED 时,下载产品 E2E 仍为红。zterm-daemon 这一唯一 TCC/可执行主体:preflight 只运行 ZTERM_DAEMON_NATIVE --permission-probe,runtime 只运行同一二进制的 remote-window-capture 子命令。权限检查只允许一次 CGPreflightScreenCaptureAccess();禁止 CGRequestScreenCaptureAccess()、等待/轮询/重试。禁止 Remote Capture app bundle、独立 raw helper、ZTERM_DAEMON_CAPTURE_NATIVE、Node 运行时编译或 screenshot fallback。缺权限/缺 capability 必须立即返回 typed error 并停止;不得切换主体或降级成功。pasteTarget.kind=remote-window;terminal surface focus 必须清掉 context;否则同一 QuickBar image action 保持 terminal Ctrl+V paste path。daemon 不猜焦点,不按 app title/window list 自行决定投递目标。smooth、可选 quality,profile 同时拥有 bitrate、capture dimensions、FPS、max frame age、interaction burst 和 overview budget。客户端唯一状态是 applied/desired/inFlight/queuedLatest/cooldown,始终 single-flight + latest-wins;相同 profile 全链 no-op,rejected/busy/timeout 必须离开 requested。daemon 先做 exact diff:bitrate-only 只改现有 sender encodings;cadence/dimensions/filter 只更新现有 SCStream configuration/contentFilter;quality update 的 stop/start capture 调用数必须为 0。空 encodings、in-place update 或 rollback 失败显式返回 typed failure,不伪造 encoding、不保留 partial applied、不重建 peer/receiver/session transport。qualityLimitationReason !== none 不能直接判网络弱,禁止 /4 bitrate 或 /2 FPS 断崖。quality/retry/revision/health/telemetry 走 control/debug side-channel,绝不能进入视频 frame 或 input action metadata。requestVideoFrameCallback/decoded frame id 驱动,一张 frame 最多一次 drawImage;overview/thumb 使用自己的 decoded callback 和 profile cadence,生产环境不保留 display-rAF 绘制 fallback。daemon focus/overview capture/convert 各最多一个 pending latest frame,新帧覆盖旧 pending,超过 profile maxFrameAge 直接丢;禁止增加 queue depth。若 bounded RGBA path 无法同时通过 smooth 1280-long-edge@45/p95<=100ms 与 quality 1920-long-edge@30/p95<=180ms,必须实现唯一 native media path 并物理删除 production RGBA stdout,禁止双 production path。@roamhq/wrtc 里 会在真实 Android recvonly offer 下生成 answer,ScreenCaptureKit 首帧和 都不能证明 Android 会收到 ;stream start 必须优先用 或其他已验证会生成 的路径,等 answer/localDescription 落地后再尝试修改已有 sender encodings。没有 encodings 或 抛错时只能显式报告 quality update unsupported,不能伪造成功,也不能为码率控制牺牲 video track。Live probe 必须处理 daemon ICE candidate,等待 事件并输出 ;offer/answer、、或只看 都不能单独证明 Android/receiver 视频可见。同一桌面窗口的 ScreenCaptureKit / WebRTC live probe 必须串行运行;并发 raw+mux 或同目标多 stream 容易让 catalog/capture 首帧 timeout,不能作为产品红证据。android/note.md 是 agent 自己看的工作台,不是主真源Connections 页 + 终端页,不是网页式主机列表页docs/ui-slices.mdbridgeHost / bridgePort / sessionName;禁止再用 host/username 混装 server 与 tmux session 语义daemon.connected=true 且 daemon.hostId 非空。rtc-device-*、client-only、disconnected/stale daemon records 即使携带旧 endpoint/session snapshots,也只能作为 account directory fact,禁止出现在 connectable server rows、drawer host rail 或 target lookup。bridgeHost + authToken live fetch sessions。只有当前 target 自身带 relay-rtc candidates / relay identity 时,才允许按 Relay target 处理;显式 transportMode='webrtc' / relay-route target 禁止在 buildBridgeTargetFromHost() 中自动解析 direct endpoint 填入 bridgeHost。100.x:3333、IPv4/IPv6/wsUrl direct rows canonicalize 到同一 daemon.hostId,否则同一 daemon 会显示成“名字 0 sessions + IP 有 sessions”。alias 只能用于 UI 分组身份,不能替代 transport route truth。relay-rtc:<hostId>,没有 Tailscale/direct endpoint。修 drawer duplicate host 时不能只看 endpoint alias;必须同时验证 saved/Home server alias input,以及 rtc-only directory session catalog 的唯一匹配路径。只有一个 Relay daemon catalog 完整包含该 direct SessionGroup 的非 missing session 名称时才可 canonicalize;多个 daemon 都匹配时保持分离并显式暴露,禁止猜测合并。switchSession 绕过 route owner;当 online Relay catalog 唯一 owns 该 daemon 时,点击该 row 必须继续走 session-open owner,用 route-aware target 复用并升级现有 session transport truth,禁止生成第二个同 tmux open tab。createSession(..., { sessionId: existingId, activate: false }),再做 explicit-resume;只切 open-tab/UI 但不重绑 transport truth 视为假修复。0.0.0.0:3333)zterm-daemon configure-relay 写入 ~/.zterm/config.toml;现有 ~/.zterm/config.json 必须经校验、原子写入和 read-back comparison 一次性迁移,迁移成功后不得继续作为 runtime fallback。daemon 只读取配置,不承载账号 UX,禁止硬编码 Relay URL 或把手工改散落配置当成最终交付。~/.local/bin/zterm-daemon 与 ~/.local/bin/wterm,写入前先清旧 symlink/file,released runner 读 config 前要迁移旧 ~/.wterm -> ~/.zterm。如果只靠手工修 PATH、手工挪目录或改已安装文件,视为未修真源。relay-ready 后由 daemon relay host client 发布 directory-update,session catalog 只能来自 tmux 枚举;枚举失败必须显式报错,禁止把失败伪造成空 sessions 的成功目录。authToken。若本机 daemon 日志显示 auth=config 且发布多条 endpoint,但生产 /api/auth/me 只回少量 endpoint 或 hasAuthToken=false,先查生产 /usr/lib/node_modules/@jsonstudio/zterm-relay-server/runtime/server.cjs 是否包含 authToken,再升级 @jsonstudio/zterm-relay-server 并 service-scoped restart zterm-traversal-relay.service。禁止先在 Android route/UI 层补第二套 token fallback。path + host:port 时,directory endpoint 的新 daemon auth token 必须先进入 traversal plan 并按 endpoint identity 去重;禁止按带 token 的完整 URL 去重,否则 stale saved token 会和 fresh directory token 并存,Auto 首连先撞 Unauthorized bridge token。rtc-direct 的 data channel open 不是最终 route success;必须有短稳定窗口,窗口内 close 要作为该 candidate 失败并继续尝试 rtc-relay。TURN-only rtc-relay 可直接发布 open。禁止把 transient direct open 投给上层后中断 Auto 链路。sentAt !== null 表示 seq 已在途等待 ACK;禁止固定 timer 重发同 seq。只有 daemon 明确 retryable NACK 才能清 sentAt 并重发,否则弱网 ACK 延迟会变成用户输入重复。~/.zterm/config.json 并按字段合并 zterm.android.daemon 与 legacy mobile.daemon,空的新字段不能遮住旧 token;这只是当前兼容事实。目标迁移完成后只读取 ~/.zterm/config.toml。全局安装后必须验证 auth=config、无 token WS 拒绝、有 token WS 能 list-sessions。desktop.remote_window_stream 的视频真源只允许来自 daemon/native ScreenCaptureKit -> WebRTC。Android <video> 只能消费 negotiated MediaStream;禁止用 screenshot loop、terminal mirror、client sparse buffer、static/mock receiver 冒充视频。I420 buffer 必须按 width * height + ceil(width/2) * ceil(height/2) * 2 分配;禁止用 floor(width * height * 1.5)。此类错误会让 @roamhq/wrtc 抛 byteLength 异常并杀掉 daemon,Android 现场表现为 Remote window stream start timed out。capture onFrame -> rgbaToI420 -> RTCVideoSource.onFrame 必须包在 stream owner 的错误收口内:失败时 stop capture、stop track、close peer、发 explicit stopped/error status,禁止异常穿透到 launchd 进程。<video>.readyState。最低黑盒 gate 是受控 AppKit/窗口 marker 通过 Android WebView canvas 采样到预期颜色;同时确认 daemon /health PID 没变、stream stop 后 capture 子进程清理干净。~/.wterm/logs/launchd-stderr.log / launchd-stdout.log,不要只看旧 ~/.zterm/logs。window-resize 请求,不是本地 aspect-fill cover/crop,也不是按手机绝对分辨率设置远端窗口。Android 必须用当前 fullscreen 可用显示容器 aspect ratio 和远端窗口当前 desktop width 计算目标 height;daemon 必须完成 AX window resize、原地 ScreenCaptureKit.updateConfiguration,并用 target/capture ACK 回写新 truth;Android 只能在 ACK 后更新本地 projection。禁止为了填满屏幕本地拉伸/裁切冒充 resize,也禁止改 tmux width、terminal mirror 或 renderer。rtc-direct 用 STUN-only direct ICE,rtc-relay 用 Relay TURN ICE。5G/Relay 下禁止让 remote-window video 用空 ICE 启动,也禁止失败后假装截图/terminal buffer 视频成功。pointerup / touchend / click 再加时间戳去重。touch / pointer 只适合手势关闭、拖拽、滑动判定;如果按钮点击在真机上失效,先收敛成单一语义路径,再补回 regression test。click,如果 drawer/sheet 在 release 后才出现在手指下面,这个合成 click 可能命中新出现的 row。任何 row selection 都必须要求 press ownership 从同一 row 内开始;没有 matching row press 的 pointer click 必须丢弃。keyboard/accessibility detail=0 仍允许。sessionId,TerminalPage 只消费这个 id 去更新 focused session-group viewport slot;禁止把 remote:<owner>::session:<name> placeholder 当 active truth,也禁止在 drawer/UI 里新增重连或 transport fallback。回归要模拟 first tap -> owner returns id -> parent rerender with active Session -> center TerminalView 直接渲染新 session,不需要第二次点击。handleOpenGroupSession(..., { activate:false, navigate:false })。预览 selection storage 只存返回的本地 sessionId target;打开失败显式报错,不存 remote:<owner>::session:<name> placeholder,不切真实 shell。mirror-fixed 下 renderer 横向裁切平移优先于 shell 抽屉/tab 手势:非左侧热区(包括右侧与中间)的横向拖动都归 TerminalView crop pan;外层 drawer swipe 只允许左侧热区 + previous 方向。禁止因为 fixed 模式需要抽屉入口而重新启用左右两侧 tab swipe。mirror-fixed 的手势优先级不能只看起点热区:若当前 horizontal offset 大于 0,右滑仍能真实回移 renderer,TerminalView 必须消费并 stopPropagation() 整次横滑;只有 offset 在手势开始前已为 0,左缘右滑才允许交给 drawer。反模式是子级只 preventDefault() 但让父级 touchend 继续解析成 previous。mirror-fixed crop pan。96px 在约 347px 宽的手机 viewport 上过宽,会把视觉上已经离开左边缘的右滑误判成抽屉。改热区时必须保留 56px 正向 + 88px 反向成对 gate,并用真机确认非边缘右滑同时满足 drawer hidden 与 fixed offset 变化。open_tab -> active_session 是否被 UI 合成 click 推进,再查 transport error projection。TerminalSessionDrawer 底部 New Session 例外:真机不能依赖 click 或 pointerup;用按钮自身单一 touchend owner,并 截断父级 drawer 手势。ImeAnchor backspace 事件路径,以及 ImeAnchor key payload 路径。KEYCODE_DEL -> Backspace、KEYCODE_FORWARD_DEL -> Delete、KEYCODE_ESCAPE -> Escape;JS 层再统一映射到终端序列并路由到当前 active session。ImeAnchor 日志显示 showSoftInput(...)=true、onCreateInputConnection() 命中,dumpsys input_method 显示 mServedView=ImeAnchorEditText、mInputShown=true、mIsInputViewShown=true,但截图无键盘且 contentTopInsets 仍接近屏幕底部 / 导航栏(例如 2505)。EditText 做对照。若普通文本框也显示 mInputShown=true / mIsInputViewShown=true 但无键盘,先判定为 IME 进程全局 ghost shown,不再继续改 zterm anchor / renderer / tmux。adb shell am force-stop com.tencent.wetype 复位 WeType;这是单包复位,不是 broad kill。复位后重新点击 zterm 键盘按钮,必须用截图和 dumpsys input_method 验证真实键盘窗口展开,contentTopInsets 应变成真实键盘顶部(例如 1509)。mInputShown=true 当成视觉闭环;不要用 toggleSoftInput()、重复 guard、renderer reflow、tmux resize、IME 高度猜测或 WebView 清缓存去补偿 WeType ghost 状态。Escape / Backspace / Delete / Ctrl+C。ImeAnchor.getState().keyboardVisible;visible 时 hide,不 visible 时 show。不要用本地 requested flag 或 keyboardInset 猜测显隐。ImeAnchor.show();native keyboardState(visible=true) 到达后,如果本次是 terminal keyboard request,也要再做一次 follow/bottom realign,覆盖 IME 上台导致的 visual viewport 缩高。否则滚到历史区后键盘上台会裁切到旧 viewport,表现为输入区/底部缺失。TerminalView 不接收 IME layout token、不触发 upstream resize、不改 daemon/tmux geometry。ImeAnchorEditText 必须保持可服务输入法的真实 rect,但 cursor 不可见;若截图出现额外蓝色/灰色 native 光标,先查 anchor setCursorVisible(false)。New connection 入口必须先进入 session picker:先列历史连接,再列当前 tmux sessions,最后才是 clean session / full formbridgeHost + authToken,必须自动刷新 tmux sessions;不要要求每次人工点击 Connect+ 的长按必须复用同一个 session picker,用于 quick new tab;普通点击再回 ConnectionssessionId。saved tab list 导入可做 import-only semantic 去重。fetchTmuxSessions(),结果必须回写同一个 sessionGroups catalog,再由 drawer 投影 remote-only rows。禁止让 drawer effect 依赖整个投影列表,否则 catalog 更新会反复触发枚举。tmux_session_unavailable 投影成全局错误 banner。routecodex 这类本地 OPEN_TABS 中的过期 session,若 tmux truth 已不存在,只能表现为缺失事实或 idle closed shell,不能影响当前 session。~/.zterm/config.toml,WTERM_MOBILE_AUTH_TOKEN 只作为显式 overrideauthToken,并在 websocket 连接阶段透传ConnectionsPage / ConnectionPropertiesPage / TerminalPage 各自散落 breakpoint0001-cross-platform-layout-profile.md 冻结设计,再进入实现ensureActiveSessionFresh() 造成刷新风暴。更改 Pn Session 和显式 移到 Pn,移动只改 workspace projection,不能关闭 session 或新建 transport。ZTerm.app,必须先退出旧实例,再打开新实例;不要直接 open -n 叠多个 app 进程污染证据single runtime · multi tabs:可以维护多个 open target tab,但同一时刻只允许一个 live websocket/runtime;不要把“可切换 tabs”误报成“并发多 live sessions”1 / 2 / 3 preset,风格靠近 iTerm2/Tabby,但不要上来做自由拖拽top / center / bottom 是用户显式分配的固定槽位,点击 peek 不得改写;stage 只基于当前 focus slot 计算 viewport projection。focus=top 时 viewport 为 empty / top / center,focus=bottom 时 viewport 为 center / bottom / empty,focus=center 时 viewport 等于固定槽位。focus 必须存 slot name,不能存 session id。抽屉点击 session 只替换当前 focus 槽位:focus=bottom 替换 bottom,focus=top 替换 top,focus=center 替换 center。禁止自动从 session 列表补邻居成 wheel,也禁止点击后循环轮转三槽位。抽屉长按/右键是唯一的槽位分配入口,打开 slot menu 后必须 suppress 下一次 click,避免菜单和 session 激活同时发生。before/center/after 边界模型。focus 在 before/top 边界时隐藏 before/top peek;focus 在 after/bottom 边界时隐藏 after/bottom peek;center 才显示两侧。UI shell 只消费 projection,不得自己补 top/bottom 或 left/right 局部判断。TerminalPageStageShell 的 sessionGroupVisible 条件、不要加 “center-only 不进 group”、不要让横屏强行进入 horizontal group、不要调整抽屉选择 session 的切换顺序;这些会破坏竖屏上中下显示和上下滚动。竖屏恢复包必须回到 1945 行为: 才走当前 mobile group stage。FileTransferSheet 只消费 StoragePermissionPlugin 返回的目录事实,不得在 UI 或 native 枚举层按扩展名、图片类型、隐藏文件名做过滤。showHidden,客户端必须请求完整列表;禁止用默认隐藏文件过滤制造“目录成功但文件消失”的假状态。file-upload-complete;禁止把上传后的远端路径写入 tmux、quick input、composer 或任何对话输入框。readFile() 把整文件转成单个 base64 送进 WebView;上传必须从 native storage owner 逐块 readFileChunk(path, offset, length) 读取,再发送既有 file-upload-start/chunk/end;本地 Markdown 预览也必须 bounded chunk 读取并截断显示。Native 纯读 helper 返回 bytes/bytesRead/eof,Capacitor plugin 才用 android.util.Base64.NO_WRAP 编码,避免大文件 OOM/WebView crash,也避免 minSdk 24 依赖 java.util.Base64。useOpenTabLifecycleEffects 的 Capacitor App.addListener('appStateChange') 只能注册一次;render-changing callbacks 必须走 latest ref,sync sheet state change 不得反复 remove/add native listener。回归 gate 是 src/hooks/useOpenTabLifecycleEffects.test.tsx 加 App.dynamic-refresh appState listener 测试,再看真机 logcat 是否仍有 add/remove flood。file-download-chunk 分块写入 native storage,并在完成后用 stat 校验本地字节数等于远端 totalBytes,失败必须显式进入 transfer error。switchRuntime: 'explicit-resume' 推进 transport;禁止 resumeActiveSessionTransport() 成功后提前 return,否则首次点击只开连接不切 UI,必须二次点击才可见。android/docs/loops/LOOP.md,机器真源是 android/docs/loops/loop-manifest.json。zterm.daily-triage 以 L1 report-only 运行:只读项目真源、报告、追加 run log;禁止 product code edits、daemon start/stop、stage/commit、push/merge。feature_id、owner、required gate 和 mainline_call_id;mainline_call_id 必须反查到 docs/wiki/mainline-call-map.json 的真实 edge_id。pnpm --dir android run test:feature-registry -- --reporter dot,其中 src/lib/loop-governance-truth.test.ts 会锁住 L1 禁动作、kill switch、manifest 和 mainline call ID。Android / daemon / shared / Mac 任一 terminal 主链改动,都必须按影响面选择验证层级,不能用低层 gate 冒充高层完成。
pnpm --dir android run test:feature-registry -- --reporter dot、pnpm --dir android exec tsc -p tsconfig.json --noEmit --pretty false。pnpm --dir android run daemon:mirror:close-loop。pnpm --dir mac test -- --reporter dot 与 pnpm --dir mac run type-check。bridge-transport、local tmux local-tmux-transport、terminal-runtime、workbench active target。汇报要求:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 1.规划 │───▶│ 2.开发 │───▶│ 3.测试 │───▶│ 4.提交 │───▶│ 5.沉淀 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
versionCodeadb install -dadb devices 为空就停止。App 只能把 traversalRelay.wsHostUrl 交给 app-update-runtime,由唯一 owner 派生 /relay/updates/latest.json。显式 user-saved manifest 不覆盖;旧私网/Tailscale server-connected 或旧 relay-injected URL 可被当前 Relay URL 替换。Relay server 必须通过 ZTERM_TRAVERSAL_UPDATES_DIR / ZTERM_RELAY_UPDATES_DIR 服务 /relay/updates/latest.json 和 /relay/updates/<apk>,且 public GET/HEAD + APK sha256 都验证通过;只在客户端改 URL 而生产 Relay 不服务更新包不算闭环。assets/logo.png 为源,同时生成 ic_launcher、ic_launcher_round、ic_launcher_foreground 的 mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi 资源。Adaptive foreground 必须按 Launcher 二次蒙版预留安全区,默认前景不超过画布 80%,背景色与 Logo 外围一致;构建后既要从 APK 解包核对 legacy/adaptive hash,也要看真实 Launcher 截图,包内字节一致不能证明最终图标未被裁切。addTransceiver(videoTrack, { sendEncodings })inactivegetReceivers().track.live<video>ontrackaddTrack(videoTrack)sendonlysetParameters()ontracktrackSeen=trueframesSentgetReceivers()<img> rendering will show a square matte on the dark video surface; use an actual alpha-capable engraved logo asset and avoid drop-shadow because an opaque source alpha draws a full square. Android WebView can expose the native gray play placeholder when a hidden muted MediaStream is only media-ready, so loadedmetadata / loadeddata / canplay / bounded same-stream polling may retry video.play() and publish debug only; they must not reveal the <video>. Reveal only after play() resolves, playing fires, or requestVideoFrameCallback reports a real frame. This is a visibility gate only and must not fake daemon capture/WebRTC success.forceRefresh 才等待 live Swift/iTerm2 枚举。requestRemoteWindowTargetsRuntime 必须复用当前打开的 SessionContext WebSocket,并用 { daemonHostId, bridgeHost, bridgePort, authToken } 作为客户端 projection cache key;同 daemon 切 session 在 TTL 内不得重复发送完整 app enumeration。closed transport 不能被客户端缓存伪装为可用。{ forceRefresh: true } 绕过 TTL,但不能把 picker blank 成大 loading 面板。remote-window-error 有机会返回;receiver track timeout 只能在 remote-window-stream-started answer 应用后启动,不能把 daemon 首帧等待时间算进 receiver attach。修视频打不开时禁止只加 daemon timeout 而不同步 message/receiver/probe/UI timeout,否则会把 daemon capture 问题误报成客户端 timeout 或无 track。2mbps/5/10/20 preset 覆盖 profile。fullscreen 是显示投影,不能隐式改变 stream 身份。src/components/terminal/WindowGroupLayout.tsx 作为 primary-plus-children 布局 owner;每个 Session 仍是自己的子容器,子 tile 点击只提升 preview 主窗口,只有主 tile 点击才激活真实 shell。禁止把 remote-window 子窗口再做成 picker 里的小窗列表,也禁止复制一套 session preview layout 到 remote-window picker。bring-to-focus + os-event target。当前 tmux-input / iterm2-api iTerm pane 路线必须显示只读并禁止发送 click/gesture/scroll/key/QuickBar input;不要把 daemon 会拒绝的路线伪装成可操作。remote-window-touch-action-runtime.ts 决定:1x 和 zoomed 单击都是远端左键;单指移动跨 8px 都在 pointer-move 阶段提交 bounded realtime pixel scroll;按住约 250ms 后移动才提交可靠 remote drag;约 500ms 静止长按是单次右键;双击切换 1x/2x。Zoomed pointerdown 不得预提交 local pan。双指同向移动在 1x 是远端 realtime scroll、在 zoomed 是本地 canvas pan;反向开合是本地 pinch。mode 一旦提交整轮锁定,pointerup 不补 release-time swipe,五秒手势不因时长丢弃,pointercancel 在 remote down 后必须可靠 release,content/letterbox 外触点返回 null 而非 clamp 边缘。focus prelude。stream start、视频 attach、fullscreen 进入、IME 上抬、picker/catalog 刷新、pinch zoom、本地 zoom-pan 都不是远端操作,不得抢焦点。客户端 focus 只作为兼容/显式 bring-front intent,不能成为真实输入是否能工作依赖。unsupported iTerm/tmux read-only 目标仍不得发布 input context 或发送 focus/input。smooth 最多 45Hz、quality 最多 30Hz,client/daemon pending depth <=2,daemon local age 分别不超过 80-100ms/150ms,receive time 永不刷新。可靠 barrier 前先 flush 连续态。必须用 raw/mux/Tailscale 的 ZTERM_REMOTE_WINDOW_PROBE_BURST=1 与 120Hz replay 证明无 post-stop tail、无重复 click/text、无 stuck button;串行等 ACK 只能证明协议通。setLocalDescription 时,只能作为 latest-only pending frame 保存,不能调用 RTCVideoSource.onFrame、不能计入 framesSent、不能发布 streaming;sender localDescription ready 后 flush,stop/cleanup 必须丢弃 pending。daemon 返回 remote_window_input_stream_missing 时,Android overlay 必须清 active stream / media stream / stats / pointer state 并显示显式失败,禁止继续显示旧 video surface 接受输入。trackSeen=true 或 framesSent=1。Live probe 必须使用动画 AppKit 目标窗口,并在 raw WebSocket、mux-channel、Tailscale mux 串行验证 stopped.framesSent >= 3;否则“连上但画面冻住”不会被测出来。ScreenCaptureKit app-window capture 的 queueDepth 当前锁为 3,禁止降回 1;单 buffer 加同步 RGBA 转换/pipe 写入会重演首帧后停刷。gesture/swipe。旧 swipe replay 只能在确认无消费者后物理删除,不得保留为隐式 fallback。AppKit live gate 必须在 pointer-move 阶段看到 scroll marker,并证明 pointerup 不追加滚动;只看 remote-window-input-result accepted=true 不算输入闭环。terminal.remote_screenshot / file-download owner,daemon 只能按 macOS windowId 或已归一化 cropRectTopLeftPx capture,Android 收到原始分辨率 PNG 后自动保存到本地 Download/zterm;截图路径不得发送 focus、不得拉前台、不得读取本地 video/canvas surface、不得新增第二套截图/文件传输 pipeline,非法 target 必须显式 error,禁止 fallback 成全屏截图。UI 必须有非布局型 overlay 动画/进度与保存/失败提示,不能只在工具栏下方放一行弱状态。screencapture -x -o -l<windowId>,否则 -l 默认会包含窗口阴影导致 PNG 大于 daemon manifest bounds。更新 packaged global daemon 时必须同时验证 android/release-dist/.../support/zterm-daemon、~/.zterm/releases/.../support/zterm-daemon、~/.zterm/bin/zterm-daemon 三者 sha 对齐;~/.zterm/bin/zterm-daemon 是 Node runtime 缺少 ZTERM_DAEMON_NATIVE 时的 fallback 路径,不能残留旧二进制。RemoteWindowOverlay.test.tsx、TerminalPage.remote-window-overlay.test.tsx、session-context-remote-window-runtime.test.ts、session-context-transfer-runtime.test.ts、terminal-file-transfer-binary-runtime.test.ts、remote-window-stream-daemon.test.ts、terminal-message-runtime.test.ts、remote-window-video-quality.test.ts、tsc --noEmit、test:feature-registry。daemon 可用时追加 live WS 显式错误/成功 smoke。TerminalPage canonical projection owner 内去重:direct/Tailscale history、Relay history 只有通过精确 online endpoint 或 saved/Home endpoint-to-online-daemon alias resolve 到同一 daemon host rail 时,同一 serverIdentity.key + sessionName 才能合并 route/close/open target metadata。Relay Session catalog 只证明 row 存在,绝不能凭同名 Session 反推 daemon identity;常见名称在多机环境会把历史记录连到错误机器。禁止在 TerminalSessionDrawer 组件里靠视觉过滤,禁止让 transport/daemon 参与 UI 去重。relayDevices.daemon.endpoints、relayDevices.daemon.sessions、saved/Home alias inputs)都必须进入 comparator 的 UI key。Relay device UI key 必须包含 session name / updatedAt,禁止只比较 endpoint/connected,否则 Relay stream 只更新 session catalog 时 drawer 会继续显示旧 host rail 或旧 session count。SessionGroupHistory 必须保留 normalized relayEndpointCandidates。drawer catalog open/close 和 session-open owner 之间要透传这些候选,否则从 drawer 点 remote-only session 会退化成 direct bridgeHost/bridgePort WebSocket,现场表现为 Relay 行可见但打开时报 ws connect timeout。useSessionOpenActions 的 session-open 主线直接进入 Terminal;点击 active Session 必须走 open-tab/session owner resume。禁止 Home 直接 create/close session、写 Host storage、恢复 cold-start tabs,或用 Relay access token 做导航 gate。/api/auth/me 验证,再查生产 store;禁止把多设备登录误判为互踢,也禁止手工清空整个账号 token 列表。sessionName 时不能直接创建 zterm-*。useSessionOpenActions.handleOpenSavedConnection 必须按 server owner 先用 last-entered history 命中当前进程 open Session;否则 live fetch tmux truth,优先进入仍存在的 last-entered session,没有历史则进入第一条远端 session;只有远端列表为空才创建 generated clean session。测试必须证明正常远端有 session 时 createTmuxSession 不调用。server + session 组合,否则多 server / 多 tmux session 场景会失真bridgeHost 已显式写成 ws://host:port / wss://host:port,Android / Mac / shared storage 都必须把这个 endpoint 当成 display / preset id / effective port 的唯一真源;表单也要同步把 Bridge Port 刷成同一个端口,禁止出现双端口假象bridgeHost 直接输入原始 host:port(如 100.127.23.27:40807),shared endpoint 真源也必须当场拆成 bridgeHost=100.127.23.27 与 bridgePort=40807;禁止再把它和独立 bridgePort 二次拼接成非法 ws URLdeviceIdaccount + hostId + concrete client deviceId 独立维护。Android 每安装实例生成并持久化自己的 relay client deviceId,/ws/client 必须携带它;Relay server 缺 deviceId 要显式拒绝,不能把匿名客户端合并或共享。普通 signaling socket close 只让该 device peer idle 30 分钟,不立刻通知 daemon close;同 device 30 分钟内重连复用 peerId 并重新 rtc-init 协商,不同手机必须得到不同 peerId。超过 30 分钟、host 替换或显式 rtc-close 才关闭 peer。验证 gate 是 pnpm --dir android run test:relay:peer-lease,并且不能把 terminal channel/tmux/mirror/UI truth 放进 lease。relay client socket replaced 关闭,并向 daemon 发起新的 rtc-init;不同 client-device 才分配不同 peerId。出现 rtc data channel closed 时先跑 peer-lease black-box gate确认 replacement 真相,禁止直接猜旧 generation 抢 lease。daemonHostId 的 saved direct/Tailscale target 不是 Relay-owned route,必须立即按自己的显式 endpoint 构造 transport,不能被 Relay directory 缺失阻断。控制面确认 daemon presence、endpoint candidates 和当前 Relay/direct auth settings 后,terminal.transport_lifecycle 才能一次性构造 Relay-owned generation 的 traversal socket;cached account/host 只能做 UI projection,不能启动 signaling。confirmed directory 无 target 必须显式失败;控制线重连不能关闭已健康的业务 transport。4408 control directory confirmation timeout 关闭该 generation,交回既有 target failure/reconnect owner。禁止让 CONNECTING generation 无限等待并阻塞后续 reconnect,也禁止用 stale host/profile 绕过 control gate。zterm-android)视为无效,必须迁移成稳定 per-install id,并同步 top-level account、nested relaySettings、startup BridgeSettings。否则升级后的两台手机仍会用同一个 /ws/client?deviceId=zterm-android 抢同一 peer lease。验证至少覆盖 legacy id 持久迁移、device stream URL/meta 使用迁移后 id、显式非 legacy id 不被覆盖、startup BridgeSettings 同步。Failed to set local answer sdp: Called in wrong state: stable 或 Failed to set ICE candidate,先查 rtc-bridge 信令顺序,不要去 UI/renderer/tmux 补偿。daemon RTC bridge 必须按 peerId 串行处理 relay signals;同一 rtc-init generation 只接受一次 offer;candidate 早于 offer/remoteDescription 时先缓冲,等 offer 应用后再 addIceCandidate。回归 gate 至少要有真实 WebRTC reorder 测试:candidate-before-offer 仍能打开 datachannel,且 duplicate offer 不触发 stable state error。account.directory;旧 TraversalRelayDeviceSnapshot[] 只能作为现有 UI 的 adapter projection,禁止在 Connections / Picker / Settings 各自从 legacy devices 反向补 endpoint/session 目录语义。private LAN IPv4 -> Tailscale/direct websocket -> rtc-direct(WebRTC hole-punch) -> rtc-relay(TURN) 排序。rtc-direct 使用 iceTransportPolicy=all 且不得携带 TURN credentials;rtc-relay 使用 iceTransportPolicy=relay 且只在 LAN/Tailscale/WebRTC direct 都失败/不可用后承担中继。运行中 socket 因心跳/物理关闭失败时必须把当前 route 记入 health cache,让下一次尝试换路;目标级 mux heartbeat 是周期更新线路健康的 owner。验证时要分别报告 resolvedPath=ipv4/tailscale/rtc-direct 或 resolvedPath=rtc-relay + resolvedRelayTransport=turn,禁止把 Tailscale/局域网直连冒充 Relay,也禁止把 TURN-only 成功说成 UDP 打洞成功。connectionState=disconnected 只能视为 transient ICE interruption,不能立刻投成 route failure;先在 TraversalSocket 内给 10s grace 并尝试 restartIce(),恢复到 connected 必须继续复用同一 data channel。只有 grace 超时、failed/closed、或 data channel 实际 close 才能记录 route failure / rebuild target transport。connected.capabilities.reliableInput.version=1 时继续 string-only;新 daemon 声明后,client 发送 {version, seq, data, sentAt, attempt},daemon ack/dedupe,retryable input_stale_transport/session_required nack 不出队并同 seq 重发,input_invalid/input_too_large 才停止。daemon 永远不能把 object payload 写成 [object Object]。/api/auth/me 控制面真源并覆盖本地 TraversalRelayClientSettings;旧固定域名 claw.codewhisper.cc 只能作为历史别名迁移到 relay.codewhisper.cc,不得继续用于 TURN/WS。刷新失败时禁止打开 /ws/devices 或继续使用 stale TURN/WS 配置冒充 Relay 可用。directory-snapshot 和 legacy devices-snapshot:directory 才携带 route-bearing daemon endpoints / tmux session catalog;devices snapshot 可能只是在线 presence。App 收到后续 endpointless devices snapshot 时只能更新 presence,不得清掉已收到的 directory endpoints/sessions,否则 Home/session-open 会拿到无 route candidates 的 daemon row 并弹 Transport closed while managing tmux sessions。回归 gate:App.relay-stream-lifecycle.test.tsx 需要先投递 directory,再投递 endpointless devices,断言 useSessionOpenActions 仍拿到 relay-rtc:<hostId> endpoint 和 session catalog。rtc-error 或 signaling socket 关闭后,client 侧 ICE candidate 回调必须检查 WebSocket readyState 后再发送,并主动收口 peer/signaling;禁止让异步 ICE candidate 往 CLOSING/CLOSED socket 继续 send(),否则用户会看到误导性的 WebSocket is already in CLOSING or CLOSED state。@roamhq/wrtc 与当前平台 @roamhq/wrtc-<platform>-<arch>/wrtc.node 随 release staging 打包;只在源码环境通过不代表全局安装可启动。bridgePort / daemon 端口 / daemon tmux session 名必须共用同一配置真源;不要在 UI、server、shell script、文案里散落硬编码buffer-head-request / buffer-sync-request 只读取当前 mirror storehead/range 请求都不得 await tmux capture / canonical rebuildcols / rows) 并渲染镜像,不能在 keyboard 显隐 / pinch / rotate 时自行 replay bufferwterm daemon start/restart/install-service 不能只看 launchd loaded;必须至少等到 daemon 端口真正监听,再允许回报 ready,避免手机首连撞启动窗口resource.daemon_target_transport 持有:同一 daemon target 只有一个低频(30 秒)mux-ping timer,logical tmux session/channel 禁止各自发 heartbeat。合法 mux frame / mux-pong 更新 physical target activity;channel switch、foreground resume、body-subscription 不得创建第二个 timer 或第二条 WebSocket。只有 physical close/error/send failure 或 target health owner 确认 physical transport 失效才进入 target reconnect;单 channel error 只重开 channel。mux-target-message(list-sessions),确认 tmux session 仍存在且 session active/live 后才重开 channel;tmux truth 缺失则投 closed,inactive 则 idle,channel 已重开则丢弃 stale control 结果。若 active/live channel 的 target control status 自身不可达或请求失败,说明控制线不可用,必须交给 target transport failure owner 退休当前物理 generation 并重建;禁止把它投成 idle/error 卡死,也禁止绕过控制线直接给 UI 投 rtc connection timeout。SessionReconnectStore 持有;禁止重新传 reconnectRuntimesRef / manualCloseRef / staleTransportProbeAtRef 三个 ref 袋子。SessionReconnectRuntime 只能是 idle | scheduled | connecting 判别联合,只有 scheduled 可带 timer;manual close 必须抑制 retryable reconnect 且不投 terminal error,scheduled/connecting 不得重复排队 reconnect。reconnecting 的前 10 秒,TerminalPage 应隐藏 network banner,并让 portrait status strip 投影 waiting/green;超过 grace 后才显示真实 reconnect/error UI。这个规则只属于 UI projection,不改变 transport/reconnect 真相。buffer-head-request,再按本地 buffer 状态决定 diff / 三屏重锚 / reading gap repair);不能再依赖第二套 active/idle 语义startScrollTop,横向手势再切 tab;否则会出现“不是从当前底部开始滚”的跳变bufferUpdateKind 去重算滚动锚点;inactive tab 应冻结 scroll/layout 推导,切回后只按“贴底/保留原 scrollTop”恢复CellData 真源必须包含 width(0/1/2):client 只能按远程 cell 宽度/continuation 渲染 cursor,不能再按本地字符宽度猜位置outputHistory replay;每个 session 必须常驻自己的 terminal 实例和本地 bufferhead + range 读接口,不补第二份语义buffer-head/info,mirror body changed 发 buffer-sync diff。client 正常模式被动吃 push;只有 resume / reconnect / stale probe 才主动 buffer-head-request,只有 reading gap repair 才主动 buffer-sync-request/debug/runtime、/debug/runtime/logs 暴露现场快照;接口复用 daemon auth token,便于服务器端直接拉取现场证据packages/shared,只允许 import 叶子模块(如 schedule/next-fire.ts、connection/types.ts);禁止从 @zterm/shared 根入口取模块,因为根入口会连带 React/CSS,直接把 daemon 运行时打崩+,横屏合并行同时保留两个;不得压成第三工具行里的单个通用入口。两个入口只复用现有编辑器 owner,不发送 terminal sequence。TerminalQuickBarShell 的 stacking context 内;顶部连接/网络速率栏必须被 dim layer 和编辑 sheet 完整覆盖。真机用速率栏中心点的 elementsFromPoint() 验证 overlay 排在 status strip 前;禁止隐藏速率 UI 或移动两行 + 入口作为补偿。\r 执行;只有剪贴板注入才追加到 draft,不要混成同一路径scrollTop;应合并成单向 cadence(如 rAF)贴底,并屏蔽程序化 scroll 反向触发 onScroll,避免底部抖动/拉扯TerminalView 的 follow 对齐若会被 active/reset/layout/audit 多个入口复用,必须先收成单一 helper;scrollTop -> follow/reading 判定也要保持纯 helper,避免同一真相在多个 effect 里分叉updateSessionViewport() 这类 worker 入口必须对完全相同的 reading viewport 去重;若从 reading 切回 follow,要同步清掉已排队的 reading sync,不要让旧 request 在 follow 态晚到active switch、follow reset 等多个入口复用,必须先收成单点 helper;不要让同一 follow 真相在两个分支各算一遍connectSession / reconnect 若重复的是 socket 握手、heartbeat、公共 message switch,就抽 transport helper;但 connected 后的状态推进、bucket 排队、副作用仍保留在各自分支,不要为了去重把两条链混成一条connected 后共享的是同一份 baseline 推进(connected state、schedule-list、active bootstrap、watchdog、connectedCount),可以再抽一层公共 helper;但 bucket reset / pending input drain / retry 队列推进仍留在各自外层finalizeFailure 若共享的是完成位、cleanup、schedule error、manual-close 终止,也可以再抽一层 failure baseline;但 retry、bucket attempt、pending requeue 仍留在各自外层TerminalView 缩 effect 面时,若重复的是 viewport refresh 调度或当前 viewport emit,先抽本地 helper(如 scheduleViewportRefresh / emitCurrentViewportState);先单点化动作,再决定是否减少 effect 数量emitReadingViewportState);renderer 收口先做动作单点化,不急着硬合并 effectresetViewportToFollow / anchorReadingViewportAfterPrepend / emitReadingViewportIfNearEdge);先把动作名字化,再看 effect 是否还能继续收becameActive 与 viewportResetNonce 最终都只是在触发同一 follow reset 动作,可以继续并成一个 reset effect;但要保住 session 切换时 ref 初始化的语义,不要把 reset 信号提前吃掉emitViewportState 的 dedupe key 仍能兜住重复发送tabIndex=-1 + blur + outline nonepointerMove 更新 React state、pointerUp 立即提交,必须用 ref 同步保存最新 dragState;release 不能只读 state 闭包,否则会出现“拖了但顺序没生效”transform(如 translateY(0));这会让内部 position: fixed 的悬浮球/面板改绑到容器坐标系,导致入口“消失”Ctrl + C 会被错误保存成 CtrlbuildTerminalShortcutSequence() 按 token 顺序消费 pending modifier,只修饰后面的第一个目标键,然后清空;例如 Shift + ← + a 编码为 \x1b[1;2D + a。不要恢复“有 modifier 只能一个目标键”的限制,也不要让平台 UI 自己实现 Shift/方向键规则。webview_devtools_remote_<pid> 给目标滚动容器打 touchstart/touchmove/scrollTop probe,并用 adb logcat 验证 defaultPrevented 与 scrollTop 是否真实变化,再决定改事件捕获还是布局resolveKeyboardLiftPx / viewport height 逻辑。若键盘弹起后出现 gap、内容缺失或 quickbar 错位,先确认 WebView 是 overlay 还是 adjustResize:已 resize 时用当前 viewport height 且 lift=0,overlay 时才用 stable height + lift。visualViewport.resize 到达;viewport listener 必须把 current layout height 写入 React UI-shell state,不能只更新 width/top inset。否则后续纯高度 resize 不触发 render,会把首帧 overlay lift 冻结成偶发过度上抬。红测必须按 keyboard-first -> layout/visual resize -> adjustResize zero-lift 顺序重放。TerminalPage 计算 terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell 用同一个 terminalImeLiftPx 上台;TerminalView 不接收 IME layout token、不触发 Android upstream onResize、不把 keyboard 高度写回 daemon/tmux。terminalChromeBottomPx = measured quickBarHeight + safeOffset,terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell bottom = safeOffset + terminalImeLiftPx。禁止为了 IME 或默认态位置引入固定 render lift;也禁止 IME active 时只裁到 terminalImeLiftPx 而不预留 measured quickbar 高度,否则会遮挡终端内容。keyboardInsetPx:IME lift 已由外层 TerminalQuickBarShell.bottom 消费,TerminalQuickBar.onMeasuredHeightChange 只能上报真实 chrome 高度。否则键盘高度大于快捷栏时会把 quickBarHeight 压成 0,stage reserve 丢掉 QuickBar 高度。terminal-keyboard-lift.ts helper;TerminalPage 只能消费 terminalBottomChromeLiftPx 并同时加到 stage reserve 与 QuickBar shell bottom。foldable portrait 必须同时满足宽屏和 height >= width,compact landscape 必须限制 height <= 480;禁止让桌面样宽屏或 stale orientation 获得 mobile bottom lift。ping 做短超时 watchdog,避免“切回 tab 还是旧画面却迟迟不重连”forceHead 不能被 active-reentry 去重 guard 吃掉:lastActiveReentryAtRef、connectedBaselineBurstGuardRef 只能抑制 passive active-reentry 重复 head;explicit-resume + forceHead 必须在同一 OPEN WebSocket 上发送 buffer-head-request,否则会出现“连接还在但后台返回/网络波动后界面不刷新”。restore-sync = 只恢复 local shell / active runtime,不开 transportexplicit-resume = 用户显式激活后才允许 resumeActiveSessionTransportSessionContextcreateSession(connect:false) 虽然不打开 WebSocket,但必须恢复 session transport host / target identity;否则前后台返回或 persisted tab resume 会出现 targetKey=null -> missing-target -> reconnect,把同一 daemon/session 错误重建成新 WebSocket。这个修复只能放在 createSessionRuntime / SessionContext transport owner,禁止在 App/TerminalPage 用 reconnect 补偿,也禁止让 reconnect planner 对 missing target 做宽松 fallback。explicit-resume/debug/runtime.transportSubscribers[0].id 不应变化,日志不得新增 missing-target / transport-detached / rebuildadaptive-phone 通过 tmux resize-window -x 收窄宽度会自动切 window-size=manual,并可能把高度冻结在进入 manual 时的值;当前产品要求仍必须让 tmux 按最窄手机宽度重排,因此 resize 只能集中在 daemon adaptive lease owner,并且最后一个 lease 消失必须恢复 baseline + set-window-option -u window-size 释放 tmux 宽度控制权。要验证这点,必须跑真实 tmux PTY 回归,而不是只看源码里有没有 -y。buffer-head-request 超时不是 WebSocket 失败真相;只要 session socket 仍是 OPEN,activity/foreground owner 只能清 stale probe marker 并在同一 socket 继续请求 head,禁止调用 reconnectSession() 重建。SessionContext 必须先发 buffer-head-request probe。短等无响应只能继续同 socket probe / 显式暴露等待状态;除物理 close/error、target mismatch、missing/closed socket 的显式 open/resume 外,禁止强制替换 socket。UI 不得自行判断 timeout / 直接重连。lastServerActivityAt,但只有 buffer-head / buffer-sync(含 mux channel 内嵌消息)能更新 lastTerminalActivityAt 并清 pending head probe。title、schedule-state、input-ack、mux-channel-opened、diagnostics 不能让绿色连接状态掩盖正文停刷。connected baseline head 只提供一个 lastConnectedBaselineAt 有界 freshness 窗口,不占长期 stale probe;窗口到期后 active tick 必须在同一 socket 继续 probe。/health 的 sessions/subscribers 与 stale inbound heartbeat 日志,禁止在 UI、renderer 或 catalog 层补 reconnect。daemon-side transport lifecycle 交付必须完成 prepared/installed runtime SHA 对齐、service-scoped restart、/health PID/uptime,以及同一 quiet mux transport 静默 11 秒后仍能发 target/catalog request 的 live smoke。opening 等 mux-ready flush;flush 需要 active/anchor channel 优先,但不得靠新建第二个 socket 实现优先级。用户 input/file payload 只能在该 session channel open 后发送;opening 状态即使 physical socket 已 OPEN 也不能写用户输入。mux-channel-message 内的普通 {type:'closed'} 是 channel retryable close,先标该 channel closed 再在同 physical socket 上重发 mux-channel-open,不要投 UI terminal closed,也不要恢复旧 per-session socket 断言。mux-channel-opened 判断。mux-channel-opened 只是 daemon 分配逻辑 channel;真正可渲染必须等同 channel connected,再拿到非零 buffer-head 或 buffer-sync。mux-channel-opened 只能清掉 channel allocation timeout,并必须立刻重挂 bounded terminal-ready timeout;如果同 channel connected 没回来,要显式 retryable failure,禁止 pending open 挂死。mux-channel-open 必须带初始 bodySubscribed,inactive channel 可 attach 但不触发初始 body capture;active 时再发 channel-bound body-subscription true + buffer-head-request。mux 模式下 body-subscription reconcile 必须通过 readSessionTransportResource(sessionId).socket / target mux socket 发送,不能读 legacy readSessionTransportSocket();后者在 mux channel 下应为 null,否则会出现“输入能发、画面不刷新”的假连接。修这类 bug 的黑盒 gate 必须是冷 daemon、一条 physical WebRTC/WS mux transport、枚举出的每个 tmux session 逐个 open/activate/render 对比;list-sessions 和 warm mirror 成功都不能算闭环。TraversalSocket。必须先通过 SessionContext.manageTmuxSessionsOnOpenTransport() 在既有 target mux physical socket 上发 mux-target-message;只要存在匹配的非 closed Session,就算 mux 尚未 ready 也不能 fallback 到第二条管理 socket,必须显式等待/报错。只有完全没有匹配 open Session/target 时,才允许走 legacy tmux-sessions.ts 管理连接。tmux-sessions.ts 的独立管理连接也不是裸协议例外:物理 transport open 后必须先发送 mux-hello 并验证 mux-ready,随后只发送带 requestId 的 mux-target-message,只接受同 requestId 的 target response。禁止裸发 list-sessions / create / rename / kill,禁止把合法 mux-ready 当作业务响应,禁止接受裸 sessions/error 作为兼容路径。useSessionProviderCoreAssemblies() result、assembly types、facade、lifecycle runtime 全链路暴露,并用 SessionContext 级黑盒 gate 证明真实 provider 能读到该 truth。否则会出现 mock 测试绿但实际链路读到 null,例如 physical socket 仍 OPEN、terminal channel 已 closed,切回时继续向死 channel 发 head 并最终 timeout。rtc data channel error / terminal mux transport closed 是 physical target failure,不是某个 tmux session 的 channel error。Android 必须在 terminal.transport_lifecycle owner 中清 target mux socket/ready 和 target heartbeat,把同 target 下所有 recoverable logical channel 统一从当前状态转成 opening replay demand,只选择一个 anchor session 触发 immediate/reset target rebuild;pending open intent 只清 timer/intent,不能再各自 fanout reconnect。mux-ready 后由 opening channel flush 统一重发 channel-open。禁止只让创建 physical socket 的 anchor session fail,也禁止每个 sibling session 各自创建/调度物理重连,否则会出现“同一条连接里有些 session 好、有些 session data channel error/空屏”的分裂投影。buffer-sync,覆盖完整源 span,让 renderer 按帧组装拿到完整尾窗;禁止裁成 live tail 丢弃源 rows。这属于 daemon mirror reader 输出有界化,不允许改 tmux truth、client renderer 或 route fallback 补偿。mux-hello / mux-ready / mux-channel-open。只跑 build:android 但没有重新 daemon:prepare-release 会导致 APK 新、daemon 旧,现场表现为 terminal mux channel open timeout。修复顺序:daemon:prepare-release -> install-global -> service-scoped restart -> /health 新 PID/uptime -> live mux smoke。traversalPathPriority。目标顺序是 LAN -> UDP direct -> Tailscale -> Relay;IPv4/IPv6 只是 UDP direct 的地址族,不是独立优先级。同网段只决定 LAN candidate eligibility,不能冒充连接成功,Android/WebView 不假设 ICMP 可用。当前 TypeScript/native Service 尚未完全对齐该顺序,必须按当前实现报告。Relay 控制心跳/目录刷新只更新未来 generation 的 endpoint truth,不得重建健康业务 transport;foreground/background 只刷新目录与遗漏正文。用户只在状态条里做显式 manual override,manual override 不改全局 Auto order,也不让 session 拥有 route truth。~/.wterm/daemon-runtime/server.cjs 仍残留旧符号(如 scheduleMirrorFlush、旧 planner/active-push 逻辑)或 /debug/runtime 仍 404,先判定为 staged runtime 未切新;必要时本地执行 prepare-global-daemon-release.sh,覆盖 ~/.wterm/daemon-runtime/ 后只对 com.zterm.android.zterm-daemon 做单服务 launchctl bootstrap/kickstartrenderBottomIndex;renderTopIndex 只能派生,reading/follow 都只改 bottom pointer,renderer 不得参与 buffer 生产或把 producer bottom 写回 sourceTerminalView 不能因 visible/precheck window 不连续而冻结上一帧,必须先渲染最新 tail + 空白 gap 占位;follow 态禁止 prefetch/request 补洞,只等 live tail 或显式切到 readingBridgeSettings,Settings 只做 preset 选择BridgeSettings 里的 terminalWidthMode 是启动排版唯一真源;storage hook 首次 render 必须同步读取 localStorage 并 normalize,禁止先返回默认 mirror-fixed 再等 effect 修正,否则 restore/connect 首帧会按错误宽度模式连接。terminalWidthMode 时必须以 Settings draft next 为真源写回 BridgeSettings;禁止用旧 current 计算后只取 .terminalWidthMode,否则用户从 fixed 点 adaptive 后保存仍会被旧 fixed 覆盖。terminalWidthMode 时,默认模式判定必须优先用 visualViewport.width;Android WebView / 折叠屏可能首帧 visual viewport 窄但 layout viewport 宽,禁止用 Math.max(innerWidth, documentElement.clientWidth, visualViewport.width) 把手机错判成 mirror-fixed。terminal-width-mode localStorage key / TerminalWidthModeManager 属于分叉真源;不得恢复。宽度模式只允许经 STORAGE_KEYS.BRIDGE_SETTINGS -> BridgeSettings.terminalWidthMode -> TerminalPage/SessionContext。requestedTerminalGeometry 只允许保存 measured cols 事实,不允许把历史 widthMode 反过来覆盖当前 BridgeSettings.terminalWidthMode。connect/reconnect/open payload 的 width policy 必须以当前 BridgeSettings 为准;否则用户从 fixed 切到 adaptive 后,旧 session geometry 会继续发 mirror-fixed。APK latest、latest.json、~/.zterm/daemon-runtime/server.cjs 含新代码,都不证明运行中的 Mac daemon 已加载新 runtime。涉及 daemon-side adaptive/mirror/scheduler 修复时,必须看 /health 的 pid/uptimeSec 是否是更新后的进程,并跑真实 WebSocket + tmux probe:发送 connect/resize widthMode=adaptive-phone cols=N 后,用 tmux #{window_width}x#{window_height} 验证列数不因 adaptive 改变。若 daemon uptime 早于 runtime 更新,先做 service-scoped zterm-daemon restart,禁止把手机端继续改成补偿路径。adaptive-phone 没有 active 客户端时必须释放本轮 adaptive lease 对 tmux 的宽度控制权:先按 owner 捕获的 baseline 恢复宽度,再 set-window-option -u window-size。禁止 daemon-start 用 orphan heuristic 自动改用户 session;只有当前 owner 持有过 lease 才能释放本轮 lease。adaptive-phone 不得写 @zterm_adaptive_width_* 持久 option;baseline 只能是 runtime 内存 metadata。若旧 daemon 已留下历史 override,先报告现场事实,除非本轮 adaptive owner 正在释放自己创建的 lease,否则不要在启动时自动清理。alternate-screen。assertTmuxSessionExists() 只能 has-session;control/capture runtime 禁止 set-option -t <session> alternate-screen off。如果 iTerm2/TUI 显示不全但 window-size=<default>,要继续查 alternate-screen local override;正确修复是删除 daemon 副作用并 unset 历史 local option,不是在客户端补偿。adaptive-phone 也只能由 daemon adaptive lease owner 请求 tmux 改变宽度。移动端调试时禁止把“客户端 cols=N”直接当成“mirror cols=N”;必须等 daemon capture / tmux pane metrics 主线回写 mirror truth,且 gate 禁止 attach/resize/startMirror 自写 mirror.rows/cols/bufferStartIndex/bufferLines/cursor。adaptive-phone 可见宽度要求是 tmux 真实重排,不是客户端 renderer 本地投影:Settings / BridgeSettings / connect payload 显示 adaptive 只证明配置和 wire,不证明 tmux 已按手机宽度重排。禁止用 renderer crop 冒充 adaptive;也禁止在现有 fixed-row virtual scroll 上用 CSS white-space: normal / height:auto / wrapper width 做浏览器自换行,这会破坏 scrollTop -> row index 映射,表现为上滚循环、buffer 反复和 IME 后底部不可见。正确路径是 client 上报 measured cols -> daemon adaptive lease owner 聚合最窄 cols -> tmux resize-window -x -> mirror capture/readback -> client 渲染。cardBorder / 亮色 shell border;深色 terminal 左侧 1px 外框会被用户看到为白条。白条排查顺序:先查 stage/pane/group center border,再查 .wterm 内部 scrollbar,再查 DOM input/focus ring;禁止改 daemon/buffer/tmux 补 UI chrome 问题。adaptive-phone wire payload 必须带有限正数 cols 才能进入 daemon adaptive width lease owner;缺 cols / NaN / 0 是协议错误,不是可用 lease。daemon 必须显式返回 adaptive_width_cols_invalid 并保持进程存活,禁止让 normalizeTerminalCols() 的异常穿透杀掉 Node。client 冷启动尚未测量真实宽度时,先发送 SessionContext 默认启动列数,后续 TerminalView resize 再覆盖为真实列数;禁止发送 widthMode='adaptive-phone' 但 cols=undefined 的半语义 payload。buffer-sync wire payload 必须覆盖首个 changed range 到最后 changed range 之间的完整连续 authoritative span;禁止只发送不相邻变更行并让 client 保留中间旧行,否则快速 TUI 输出会出现旧 buffer 闪回。若需要降带宽,必须先设计多 range wire contract 和 client apply 红测,不能把有洞 payload 伪装成完整窗口。tail-refresh / reading-repair 如果匹配 pending request 的 knownRevision 和 targetHeadRevision,并且实际冲突行范围落在 pending 窗口内,即使 daemon 返回的是更大的稳定 superset body,也必须允许覆盖非 gap 行。否则会出现底部 live 行继续刷、输入框/上方可见行不刷新的分裂画面。红测必须同时锁住“无 pending request 或冲突越界的 same-revision stale overwrite 仍 drop”和“pending visible refresh same-revision superset 可 apply”。<video> 在播放/帧证据前不得暴露 WebView/系统原生灰底播放占位;pending/unplayed 状态只显示 ZTERM engraved wallpaper,并在 MediaStream attach、metadata、canplay、用户 pointer 进入时重试 play()。loadedmetadata / loadeddata / canplay / readyState poll 只能做 retry/debug,不能 reveal。Reveal 只接受 play() resolve、playing 或 requestVideoFrameCallback 真帧回调。这只是 projection/播放启动修复,不能当成 ScreenCaptureKit/WebRTC 连接成功证据。mix-blend-mode 遮白底;Android WebView 可能仍显示方形卡片。必须用实际透明 alpha 的 engraved logo asset,测试锁住不含大字、不依赖 blend mode。remote-window-stream-started 并应用 answer 后启动。否则手机会看到误导性的 Remote window stream start timed out。remote-window-input 显式协议和 daemon injectInput owner;Android 只按 manifest crop 归一化 click/gesture/key intent,不能在 UI 做 macOS 坐标真源或假注入成功。bottomInsetPx,不能只抬入口悬浮标;fullscreen 继续只服从 safe-area。远程 IME committed text 保持原始 CJK/特殊字符/换行,不走 terminal punctuation/newline normalization。bottomInsetPx 作为 overlay padding,并允许本地 letterbox pan 避开键盘遮挡;这只是 Android projection,不得改变 page shell layout、daemon capture/crop、Mac 坐标、tmux 宽度或 terminal renderer。scroll,pointerup 不发送 swipe replay。Direct Touch 双指同向移动在 1x 是 realtime remote scroll、zoomed 是 local pan;Mouse Emulation 双指仍是 remote wheel;pinch 是 local zoom。DOM delta 到 macOS CGEvent 的符号转换只在 daemon 注入 owner 做一次,Android 不做第二次取反。真实动作前 daemon 继续即时校验 frontmost/focused target。os-event 输入必须有 Accessibility 权限并使用 bring-to-focus;只 NSRunningApplication.activate 不够,尤其是微信/被遮挡窗口。daemon input config 必须带目标 windowId/title/bounds,按 CGWindowList bounds 匹配 AX window;每个真实 click / gesture / wheel / key / QuickBar / IME 动作先用 concrete PID(System Events unix id)判断当前 frontmost,目标未前台时再执行 System Events frontmost + AXRaise / focused window,验证 frontmost + focused 后再发 Quartz event;目标已经前台/focused 时不要再排一次 focus。client focus intent 只作兼容/显式 bring-front,真实输入不得发送 focus prelude;stream start、视频 attach、fullscreen、IME lift、pinch/local pan 不得抢焦点。iTerm2 pane/read-only target 禁止发送这些 intent。NSRunningApplication.activate() 返回 true 不能当成功,必要时用 System Events 按 PID 置前台,并对同 PID NSRunningApplication(processIdentifier:) 做短重试后再显式报错。CGWindowList top-left 坐标可直接用于 CGEvent location,禁止再用 AppKit bottom-left 转换。no-focus-steal + os-event 必须显式报错,不能宣称输入成功;验证应使用“目标窗口被 cover window 遮住,未 AXRaise 不动,AXRaise 后真实滚动”的黑盒。os-event 输入不能每个事件启动一次 swift -e;点击、滚动、键盘是连续流,daemon 必须用唯一常驻 Swift helper。交互式 app-window stream start 必须先 warm 并等待 helper ready,且不得发 focus/input;Swift schema 必须匹配 wire union:scroll/click 没有 phase,daemon-only legacy gesture 要求 phase=end,legacy pointer/key 才要求 phase。黑盒必须通过运行中 daemon WebSocket 启动真实 app-window stream,对受控 AppKit 窗口发送 click/gesture/scroll/key 并读取目标 stdout,再把 iTerm2 切前台后用 harmless click 或 focus intent 验证目标 App 可被 AXRaise 到前台。remote-window-input;TerminalPage 测试证明 selected target manifest 进入 remote-screenshot-request 并自动落盘;daemon file-transfer 测试证明 app-window 走 --window-id、iTerm pane 走 --rect、非法 target 不 fallback;native Swift 至少编译并通过 1x1 rect capture;若 daemon 已运行,必须 service-scoped restart 后验证 installed native 支持 --window-id/--rect。daemon:prepare-release -> 安装 release 包内 install-global.sh 或等价 global release -> ~/.local/bin/zterm-daemon install-service / service-scoped restart -> /health 新 PID/uptime -> launchd runner 确认 exec /Users/fanzhang/.zterm/releases/zterm-daemon/<version>/runtime/server.cjs 而不是旧 ~/.zterm/daemon-runtime/server.cjs -> installed runtime SHA 等于 android/release-dist/.../runtime/server.cjs -> live catalog/input smoke。否则 Jason 升级 APK 会看到“没有任何变化”。tap -> pointer-move-phase scroll -> five-second hold-drag -> cancel-release -> key 驱动 typed delivery lanes;既观察 matching ACK/result,也观察目标进程 file-backed OS markers,并证明前台/焦点切换。固定 raw/mux 入口仍是 scripts/remote-window-live-input-probe.ts;clock-skew、burst、120Hz、Tailscale/当前 active route 必须串行跑,证明 daemon 只用本地 receive age、连续频率 <=45Hz、pending <=2、可靠 seq 去重且无 post-stop tail。没有 raw+mux+burst+clock-skew+120Hz,只能说协议局部通过;没有在线 ADB/CDP/真机触摸,只能说 daemon/AppKit loopback 闭环。Date.now()。并发 raw/mux/Tailscale probes 会在同一毫秒启动,Date-only id 会复用同一个 stream/channel/request 前缀,制造 remote_window_stream_exists 或 stale window 假红;标准做法是串行跑产品 gate,工具自身也要防碰撞。createDefaultRemoteWindowInputHelper() 时,不要把 swiftBinary 伪造成 shebang 脚本来吃 -e;macOS 上首参 -e 会让脚本体不按预期执行。用可注入 process factory / child stream mock 锁 helper 队列语义,live gate 再用真实 swift -e。TerminalPage 打开 picker/interactive app-window,对 video surface 重放 1x/zoomed tap、move-phase scroll、hold-drag、zoomed pair pan、pinch、five-second release 和 cancel-release;证明连续动作经过 coalescer、可靠动作经过 sequence/ACK owner、无 client focus prelude、无 pointerup swipe、无 terminal input 泄漏。isolated overlay/QuickBar/IME 通过不能代表 page physical-send path 通过。ImeAnchorPlugin 是 terminal 和 remote-window 共用软键盘 owner。默认必须 setShowSoftInputOnFocus(false),但显式键盘按钮触发 show() 时要随 anchor enabled 同步允许 soft-input-on-focus;hide/blur/editor-active 再禁用。现场验证不能只看 showSoftInput() 返回 true 或 anchor hasFocus,必须看 keyboardVisible/keyboardHeight 或真机截图有可见软键盘。SEND Y。键盘仍用 ImeAnchor.getState() 和截图证明可见;触摸同时要有 CDP/Network typed delivery frame、matching ACK/result、受控 AppKit PHONE_E2E_MOUSE_*/PHONE_E2E_SCROLL,并量化 1x/2x、scroll-during-move、five-second/cancel release、120Hz <=45Hz 和无重复可靠 action。状态栏 diagnostics 只定位,不是闭环证据。状态浮窗:CTX 是否 active、context target/route/focus policy、client/session/stream/target id、last source、SEND Y/N、last pointer/key/scroll、坐标、focus/down/move/up/scroll/key/text 计数,以及 daemon RW结果 ACK/ERR 与 accepted/error 计数。Jason 截屏后按分界判断:计数不动是 Android WebView hit-test/overlay path;SEND=N 是 context/sendInput wiring;SEND=Y 但无 RW结果 是 SessionContext result dispatch / mux channel return path;RW结果 ERR 进入显式 daemon error owner;RW结果 ACK 且坐标正确但远端不动,才进入真实 target focus/coordinate/AX/Quartz 注入。clientSentAt 只属于 debug side-channel,跨端墙钟不能拒绝动作。连续 age budget 由 active profile 决定(smooth 80-100ms、quality 150ms);可靠 sequence/barrier/release/key/click 不走连续 stale,而是 ACK/dedupe/有界 retry。Image paste/Command+V 继续由现有 file-transfer/remote-window owner 构造,不得在 server.ts 胶水层手拼或把 delivery control 混进 action metadata。stopPropagation()TerminalSessionDrawer row 内关闭 × 也必须有自身 touchend 激活路径;父 row 有 touch/long-press 手势时,close button 要 stopPropagation()、清长按 timer、去重 synthetic click,测试必须覆盖 touch close 不触发 select。drawer:touchstart 而没有 add:*,不要继续猜 click/pointer/touch,也不要直接下“遮挡”结论;先加 cap:start/end:<target> 确认真实命中节点。TerminalSessionDrawer 底部 New Session 的语义 owner 应放在整个 footer hit surface,而不是只放在内部可视 button 上。!splitVisible && !landscape && centerwidth / height <= 0.4)强制 vertical,上下滚;宽竖屏默认 horizontal,但 Settings 可切 vertical;landscape 永远 horizontal。这个设置只影响 layout projection,禁止改写 drawer 固定槽位、session/tab/pane 真相。TerminalPage.session-content-identity.test.tsx 的 session/body marker 门禁,确认 activeSession -> session-group slot -> TerminalPageStageShell -> TerminalView 同源;不要先改 WebSocket。外部 active session 改变时,如果 active 已在 top/center/bottom slot,就 focus 该 slot;如果不在任何 slot,就替换 center。禁止让 resolveTerminalSessionGroupSlotIds() 只在旧 center 缺失时才 fallback 到新 active。src/lib/server-identity.ts:用户可见 label 优先是 connection/daemon 名,颜色也按同一 server key;禁止在 drawer、side peek、tab 文案里把 bridgeHost:bridgePort 或 telnet/bridge 端口当服务器名。server-identity alias 输入必须同时来自 live sessions、session groups、Relay daemon endpoints;只从 live sessions 建 alias 会漏掉“目录里有 daemon,历史 group 用 IP”的常见路径。src/lib/server-color.ts 的固定红/黄/蓝/绿/青/橙 palette;禁止连续 hue hash 漂到紫/粉区,常见服务器 key 需要测试锁住不同色。TraversalSocket 或断言 WebSocket 实例/线路选择的测试必须在 beforeEach 清 defaultTraversalRouteHealthCache,否则前一用例记录的坏线路会让后续用例不创建 socket,表现为 MockWebSocket.instances 为 0。