소스 정보
- 저장소
- Jasonzhangf/zterm
- 최근 소스 활동
- 2026년 9월 6일 04:51
- 감지된 SKILL.md 언어
- 다국어 혼합
- 스타
- 0
- 포크
- 0
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
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。