Skip to main content سوق المهارات اكتشف واستكشف مهارات الذكاء الاصطناعي التي بناها المجتمع.
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
نسخ Promptعرض تفاصيل Prompt يتجاوز الأمر المباشر Prompt المخصّص للمراجعة. افحص المصدر قبل تشغيله.
npx skills add https://github.com/Jasonzhangf/zterm --skill zterm-mobile-devيبقى الأمر في سطر واحد. مرّر أفقيًا لمراجعته كاملًا قبل النسخ.
تفضّل نسخة محلية؟ نزّل الملفات المتاحة حاليًا لدى SkillsMP.
تحميل Zip جاري التحميل...
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub versionCode
adb install -d
构建后必须继续做 Android 运行态 smoke:优先使用在线 ADB 真机;若无在线真机,立即启动并使用可用 Android Emulator 完成安装、启动、重启和真实 UI/网络路径验证。只有在本机既无在线真机也无可启动 Emulator 时,才允许把 L5 记为环境阻塞;不得仅因 adb devices 为空就停止。
禁止把源码修复、单测、typecheck、daemon close-loop 当成可供 Jason 复测的交付物;没有升级 APK,就不算移动端交付闭环。
Relay 场景下升级地址必须跟随当前 Relay 公网路由: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 不服务更新包不算闭环。
public Relay 发布参数读取构建脚本与当前已授权部署配置,核实 SSH host/key、updates dir 和 manifest URL;不把 skill 的历史地址当运行真源。发布 normal/rollback 及 manifest 实际引用资产后,从公网 GET/HEAD 并下载比对 SHA-256;仅有服务器文件不算闭环。
替换 Android App Logo 时,以仓库根 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 })
inactive
getReceivers().track.live
<video>
ontrack
addTrack(videoTrack)
sendonly
setParameters()
ontrack
trackSeen=true
framesSent
getReceivers()
Remote-window pending/unplayed receiver wallpaper is Android projection only. If the logo source is an opaque RGB asset, normal <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.
Remote-window app catalog 是 daemon-wide cache,不是 tmux/session truth。daemon runtime 启动时必须 warm 默认 app-window+iTerm2 catalog,并以 requested source set 建立 60 秒进程内 cache:fresh 直接返回,stale 立即返回并只启动一次后台 refresh,首次无 cache 或显式 forceRefresh 才等待 live Swift/iTerm2 枚举。requestRemoteWindowTargetsRuntime 必须复用当前打开的 SessionContext WebSocket,并用 { daemonHostId, bridgeHost, bridgePort, authToken } 作为客户端 projection cache key;同 daemon 切 session 在 TTL 内不得重复发送完整 app enumeration。closed transport 不能被客户端缓存伪装为可用。
Remote-window picker 的 Android overlay 可以持有当前 active session 的上一份 catalog projection,用于 reopen / stale refresh / force refresh 时先显示旧 rows;daemon-wide cache 真源仍只在 SessionContext catalog owner。手动刷新必须显式传 { forceRefresh: true } 绕过 TTL,但不能把 picker blank 成大 loading 面板。
Remote-window catalog/capture timeout 必须按 owner 对齐:Android catalog watchdog 要长于 daemon app-window catalog timeout,live probe catalog wait 也要更长;stream-start request timeout 要长于 daemon ScreenCaptureKit startup timeout 加控制面/Relay 预算,让 daemon 显式 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。
Remote-window 质量参数由 stream-local typed profile 唯一决定,按本节 quality 规则与 remote-window decision 核对;不再用旧 2mbps/5/10/20 preset 覆盖 profile。fullscreen 是显示投影,不能隐式改变 stream 身份。
Remote-window picker 默认只直接列 app-window;iTerm2 panes 必须折叠到一个可展开组里,避免和普通 app 窗口混在一个长列表。daemon catalog 仍返回完整 targets,折叠只是 Android overlay projection。
Remote-window APP 多窗口的小窗口切换属于正在观看的视频层,不属于 picker 列表。Picker 同一 APP 只显示一个 collapsed app row;打开后在 target-locked video overlay 内显示同 APP sibling window switcher,点击 sibling 直接切换 concrete target stream。切换必须是事务式 stream handoff:新 stream start/attach 成功后才 stop 旧 stream;新 stream 失败时保留当前 video/input context 和当前 sibling UI,不把失败投成当前连接断开。普通 tmux 多 session 预览仍复用 src/components/terminal/WindowGroupLayout.tsx 作为 primary-plus-children 布局 owner;每个 Session 仍是自己的子容器,子 tile 点击只提升 preview 主窗口,只有主 tile 点击才激活真实 shell。禁止把 remote-window 子窗口再做成 picker 里的小窗列表,也禁止复制一套 session preview layout 到 remote-window picker。
Remote-window input context 只允许发布给已验证支持的 app-window bring-to-focus + os-event target。当前 tmux-input / iterm2-api iTerm pane 路线必须显示只读并禁止发送 click/gesture/scroll/key/QuickBar input;不要把 daemon 会拒绝的路线伪装成可操作。
Remote-window floating resize 必须是 Android overlay projection:至少覆盖左下角和右下角拖拽,按 selected source aspect ratio 等比缩放,右下角扩缩时保持左边稳定并移动右边,左下角扩缩时保持右边稳定,并且放大时要 cap 到 toolbar 仍在 viewport 顶部安全边界内。测试不能只证明“有一个 handle”。
Remote-window floating toolbar 只能把标题、拖动 hit zone、fullscreen/shrink 和 close 放在固定顶栏;码率、滚动幅度、方向、截图、键盘等调参控件必须进独立横向滚动 control strip。手机宽度下若把所有控件塞进 drag row,会再次造成无法移动或关闭。
Remote-window Direct Touch 的唯一 gesture arena 语义由 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 边缘。
Remote-window Mouse Emulation 保留远端 pointer move/down/up/drag、click/double-click 和双指 wheel;pinch 仍是本地 zoom;本地 pan 只走显式 hand/pan control,不与远端 wheel 复用。Direct Touch 和 Mouse Emulation 都必须共用一个 source/surface inverse transform;1x、2x、最大 pan 坐标误差不超过 2 source px 或 0.5%。修手势必须跑 runtime 正反测、overlay/page physical-send、1x/zoomed AppKit marker、五秒 drag/cancel、letterbox null 和 120Hz coalescing,禁止在 daemon 做 Android 手势补偿。
Remote-window 支持的 app-window 输入焦点真源在 daemon input owner:click / gesture / wheel / key / QuickBar / IME 每个真实动作进入 Swift helper 后,先按 System Events live frontmost PID + AX focused window 判断目标 app/window 是否已经前台;不是则 bring-to-front/AXRaise/focus 并验证,是则不做 focus 再直接注入。Android 真实输入只发送一个用户 action record,不得在 click/gesture/wheel/key/QuickBar/IME 前额外发送 client 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。
Remote-window 输入交付分可靠 ordered lane 与连续 mergeable lane。可靠 down/up/cancel-release/click/key/text/paste/focus/resize/barrier 使用稳定 sequence、ACK/NACK、dedupe 和有界同-sequence retry,绝不受连续 stale/overflow 丢弃;连续 move 只保留最新、scroll 累加,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 只能证明协议通。
Remote-window 首帧和 stale stream 有独立 gate:ScreenCaptureKit 第一帧早于 daemon 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 接受输入。
Remote-window 视频刷新不能只看 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 写入会重演首帧后停刷。
Remote-window 普通滚动主线只接受实时 pixel scroll,不再生成或回放 release-time gesture/swipe。旧 swipe replay 只能在确认无消费者后物理删除,不得保留为隐式 fallback。AppKit live gate 必须在 pointer-move 阶段看到 scroll marker,并证明 pointerup 不追加滚动;只看 remote-window-input-result accepted=true 不算输入闭环。
Remote-window 截屏是 target-scoped capture,不是输入事件:浮窗和 fullscreen 的截图按钮必须把 selected target manifest 交给现有 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 动画/进度与保存/失败提示,不能只在工具栏下方放一行弱状态。
macOS app-window 原始截图必须用 native daemon 的 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 路径,不能残留旧二进制。
相关改动最小 gate: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。
Terminal drawer 枚举必须在 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 去重。
TerminalPage 是 memoized 组件;任何影响 drawer host identity / session enumeration 的输入(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。
Android Home 改版为 server-entry-only 时,仍必须保留显式可见 的设置/升级入口;不要只留无文字齿轮或把入口藏在 Relay/login 语境里。Terminal 竖屏 shell 也必须有 Settings 入口,因为用户可能长期停留在终端页。升级实现仍只属于 Settings 的 App Update owner,Home/Terminal 只能发 open-settings intent,不新增第二套升级逻辑。
Home 点击 saved Host 必须把 intent 交给 useSessionOpenActions 的 session-open 主线直接进入 Terminal;点击 active Session 必须走 open-tab/session owner resume。禁止 Home 直接 create/close session、写 Host storage、恢复 cold-start tabs,或用 Relay access token 做导航 gate。
Relay 账号是多设备账号:一次成功登录只能 append 一个独立 token,不得替换同账号其他 client/daemon 的 token。普通密码重置默认保留现有 token;只有 Jason 明确要求“退出所有设备”时才能全量撤销。现场某台手机突然 401 时,先用该手机真实 token 对 /api/auth/me 验证,再查生产 store;禁止把多设备登录误判为互踢,也禁止手工清空整个账号 token 列表。
Home server row 无 saved sessionName 时不能直接创建 zterm-*。useSessionOpenActions.handleOpenSavedConnection 必须按 server owner 先用 last-entered history 命中当前进程 open Session;否则 live fetch tmux truth,优先进入仍存在的 last-entered session,没有历史则进入第一条远端 session;只有远端列表为空才创建 generated clean session。测试必须证明正常远端有 session 时 createTmuxSession 不调用。
terminal header / live session / tab 文案必须能直接看出 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 URL deviceId
Relay WebRTC signaling peer lease 必须按 account + 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。
同 account/host/client-device 的新 signaling socket 必须顶掉旧 socket:复用同一 peer lease,旧 socket 以 relay client socket replaced 关闭,并向 daemon 发起新的 rtc-init;不同 client-device 才分配不同 peerId。出现 rtc data channel closed 时先跑 peer-lease black-box gate确认 replacement 真相,禁止直接猜旧 generation 抢 lease。
Relay 已登录且 target 同时有 stable daemon id 与 Relay endpoint/signaling/WebRTC route evidence 时,新业务 transport 必须等待 fresh confirmed control directory。仅有持久化 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。
Control-confirmed waiting generation 必须有 10s bounded timeout;超时用 typed 4408 control directory confirmation timeout 关闭该 generation,交回既有 target failure/reconnect owner。禁止让 CONNECTING generation 无限等待并阻塞后续 reconnect,也禁止用 stale host/profile 绕过 control gate。
Android Relay client identity migration 必须在打开任何 Relay socket 前完成:旧安装持久化的固定 id(如 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 同步。
Relay 网络切换后若 daemon 日志出现 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。
Android relay account directory 的 client 真源是 account.directory;旧 TraversalRelayDeviceSnapshot[] 只能作为现有 UI 的 adapter projection,禁止在 Connections / Picker / Settings 各自从 legacy devices 反向补 endpoint/session 目录语义。
Relay 登录态的默认 route 策略是 fast-path-first,不是 WebRTC 优先也不是 TURN-only:同一 daemon target 必须按 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 打洞成功。
WebRTC 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。
terminal 输入协议必须按 capability 分叉:旧 daemon 未声明 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]。
Relay account device stream 打开前必须先刷新 /api/auth/me 控制面真源并覆盖本地 TraversalRelayClientSettings;旧固定域名 claw.codewhisper.cc 只能作为历史别名迁移到 relay.codewhisper.cc,不得继续用于 TURN/WS。刷新失败时禁止打开 /ws/devices 或继续使用 stale TURN/WS 配置冒充 Relay 可用。
Relay account device stream 必须区分 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 signaling 收到 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。
发行包验证必须覆盖 native runtime 依赖:TURN/RTC 需要 @roamhq/wrtc 与当前平台 @roamhq/wrtc-<platform>-<arch>/wrtc.node 随 release staging 打包;只在源码环境通过不代表全局安装可启动。
验证过程中产生的临时 tmux session 需要及时清掉,只保留一个明确实验 session,避免把测试垃圾当成真实 session 列表
bridgePort / daemon 端口 / daemon tmux session 名必须共用同一配置真源;不要在 UI、server、shell script、文案里散落硬编码
daemon restart/status 只证明 tmux session 存在,不等于 socket 已 ready;验证时至少补一次端口监听检查或真实 WebSocket probe
daemon 的唯一职责是 维护 tmux truth mirror ;它不关心 client 本地 buffer、follow/reading、首屏、gap、渲染窗口 daemon 内部必须 writer / store / reader 解耦 :
writer:tmux sync / input / resize / live tick 更新 mirror
store:维护 canonical buffer、absolute line index、revision、available range
reader:buffer-head-request / buffer-sync-request 只读取当前 mirror store
禁止 read request 触发 write path :任何 head/range 请求都不得 await tmux capture / canonical rebuild
terminal 排版真源在 daemon / tmux;client 只上报 viewport(cols / rows) 并渲染镜像,不能在 keyboard 显隐 / pinch / rotate 时自行 replay buffer
wterm daemon start/restart/install-service 不能只看 launchd loaded;必须至少等到 daemon 端口真正监听,再允许回报 ready,避免手机首连撞启动窗口
websocket mux bridge 的 heartbeat 只能由 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 data channel close 必须先走 target control status:通过现有 mux target transport 发送 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。
reconnect 相位、manual close、stale head probe 必须统一由 SessionReconnectStore 持有;禁止重新传 reconnectRuntimesRef / manualCloseRef / staleTransportProbeAtRef 三个 ref 袋子。SessionReconnectRuntime 只能是 idle | scheduled | connecting 判别联合,只有 scheduled 可带 timer;manual close 必须抑制 retryable reconnect 且不投 terminal error,scheduled/connecting 不得重复排队 reconnect。
短暂重连不要制造断连感:网络在线且 active session 处于 reconnecting 的前 10 秒,TerminalPage 应隐藏 network banner,并让 portrait status strip 投影 waiting/green;超过 grace 后才显示真实 reconnect/error UI。这个规则只属于 UI projection,不改变 transport/reconnect 真相。
websocket reconnect / 首次 connect 完成后,active tab 必须立刻恢复 head-first 主循环(先 buffer-head-request,再按本地 buffer 状态决定 diff / 三屏重锚 / reading gap repair);不能再依赖第二套 active/idle 语义
scrollback 若通过 DOM prepend/trim 历史行,client 在“未贴底”时必须保 scrollTop 锚点;否则持续输出后回滚会像 buffer 丢失
手势滚动进入历史阅读态后,scroll lock 要做成 latch,直到真实输入发生才允许恢复 bottom-follow;不能靠“回到底部”自动解锁
terminal 单指手势要先做 axis lock:竖向滚动在“确认纵向手势的那一刻”重取 startScrollTop,横向手势再切 tab;否则会出现“不是从当前底部开始滚”的跳变
多 tab terminal 在 hidden → active 切换时,不能拿 hidden 期间最后一次 bufferUpdateKind 去重算滚动锚点;inactive tab 应冻结 scroll/layout 推导,切回后只按“贴底/保留原 scrollTop”恢复
mobile 光标不要额外开本地 blink 动画;只消费 bridge/buffer 的 cursor 位置,避免字体/viewport 变化后出现视觉错位
若要让 mobile 光标忠实镜像 tmux,CellData 真源必须包含 width(0/1/2):client 只能按远程 cell 宽度/continuation 渲染 cursor,不能再按本地字符宽度猜位置
多 tab terminal 不允许只保留一个 active TerminalView 再靠 outputHistory replay;每个 session 必须常驻自己的 terminal 实例和本地 buffer
terminal 持久化缓存不允许只拼 raw output chunk;应从本地 absolute-index sliding buffer state 按行持久化
daemon 的初次 canonical capture 不能静默失败;capture 出错必须显式报错/记证据,但 daemon 仍只保留 head + range 读接口,不补第二份语义
daemon 的 buffer 真源必须按 tmux session mirror 维护:一个 websocket/tab 只是客户端,不得拥有自己的 authoritative buffer;客户端 detach/reattach 不能重建 session 镜像
2026-05-06 新冻结:daemon 正常模式负责 持续 mirror capture + live push ;mirror body unchanged 发 buffer-head/info,mirror body changed 发 buffer-sync diff。client 正常模式被动吃 push;只有 resume / reconnect / stale probe 才主动 buffer-head-request,只有 reading gap repair 才主动 buffer-sync-request
2026-04-23 新冻结:client buffer 必须是 sparse absolute-index buffer ,允许不连续;worker 不为“完整性”主动补洞,只围绕当前工作集补缺:follow 维护尾部 3 屏热区,reading 只补当前窗口
2026-04-23 新冻结:renderer 只按 latest bottom-relative window 消费 buffer;UI shell 只负责容器位置/裁切;IME/keyboard 不得进入 buffer/render truth 链
runtime 远程排障接口应收敛到 daemon HTTP:client 侧 runtime debug 只负责上送有界日志队列,daemon 侧统一缓存并通过 /debug/runtime、/debug/runtime/logs 暴露现场快照;接口复用 daemon auth token,便于服务器端直接拉取现场证据
Node/daemon 侧若要复用 packages/shared,只允许 import 叶子模块 (如 schedule/next-fire.ts、connection/types.ts);禁止从 @zterm/shared 根入口取模块,因为根入口会连带 React/CSS,直接把 daemon 运行时打崩
悬浮球快捷菜单的语义是“文本 snippet 注入”;方向键 / Esc / Tab / Backspace 属于常驻快捷栏,不要和自定义 snippet 共用同一概念模型
QuickBar 自定义快捷按钮编辑入口属于两条快捷行各自的 UI projection:竖屏第一、第二行末尾各保留一个 +,横屏合并行同时保留两个;不得压成第三工具行里的单个通用入口。两个入口只复用现有编辑器 owner,不发送 terminal sequence。
QuickBar 全屏快捷键编辑器必须投影到 page root/body 的顶层 overlay,不得困在 TerminalQuickBarShell 的 stacking context 内;顶部连接/网络速率栏必须被 dim layer 和编辑 sheet 完整覆盖。真机用速率栏中心点的 elementsFromPoint() 验证 overlay 排在 status strip 前;禁止隐藏速率 UI 或移动两行 + 入口作为补偿。
session 级“定时发送”入口不要挂在 tab strip / header 这种易被理解成全局 tab 动作的位置;Android 侧优先放在当前 session 的 quick input/composer 入口里
悬浮球若持久化的是绝对拖拽坐标,mount / viewport resize 时必须自动 re-clamp 到可视区;不能只在拖动瞬间 clamp,否则旋转/尺寸变化后用户会丢入口
悬浮菜单打开时可以隐藏底部 shell rows,但关闭后必须立刻恢复;keyboard 弹起时只上抬 shell rows,本体悬浮球/面板不要跟着复用同一 transform
悬浮菜单内的快捷输入列表点击语义是“立即发送 snippet”,默认补 \r 执行;只有剪贴板注入才追加到 draft,不要混成同一路径
terminal follow 态不要在每次 buffer/input 到来时直接同步硬改 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 态晚到
follow viewport state / bootstrap 这类 transport 决策若会被 active switch、follow reset 等多个入口复用,必须先收成单点 helper;不要让同一 follow 真相在两个分支各算一遍
connectSession / reconnect 若重复的是 socket 握手、heartbeat、公共 message switch,就抽 transport helper;但 connected 后的状态推进、bucket 排队、副作用仍保留在各自分支,不要为了去重把两条链混成一条
若 connect / reconnect 在 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 数量
同理,reading viewport emit 若在 prepend 历史重锚和 near-edge reading 两处重复,也先抽本地 helper(如 emitReadingViewportState);renderer 收口先做动作单点化,不急着硬合并 effect
follow reset、prepend 历史锚定、near-edge reading emit 这类 viewport action 若还散在 effect 里,也继续抽本地 action helper(如 resetViewportToFollow / anchorReadingViewportAfterPrepend / emitReadingViewportIfNearEdge);先把动作名字化,再看 effect 是否还能继续收
若 becameActive 与 viewportResetNonce 最终都只是在触发同一 follow reset 动作,可以继续并成一个 reset effect;但要保住 session 切换时 ref 初始化的语义,不要把 reset 信号提前吃掉
同理,若‘当前 viewport emit’与‘reading near-edge emit’只是同一阶段里的两次 emit,也可并成一个 effect;前提是 emitViewportState 的 dedupe key 仍能兜住重复发送
tab strip / shell header 不要保留浏览器默认 focus ring;移动端若无键盘导航需求,容器与 tab 按钮默认 tabIndex=-1 + blur + outline none
拖拽排序类交互若在 pointerMove 更新 React state、pointerUp 立即提交,必须用 ref 同步保存最新 dragState;release 不能只读 state 闭包,否则会出现“拖了但顺序没生效”
keyboard 关闭态不要在 quick bar / bottom overlay 外层保留空 transform(如 translateY(0));这会让内部 position: fixed 的悬浮球/面板改绑到容器坐标系,导致入口“消失”
快捷按键编辑器里,组合键默认名必须来自最终组合 preview,而不是第一个被点击的 modifier token;否则 Ctrl + C 会被错误保存成 Ctrl
Android / Mac 若都要消费快捷按键组合规则,编码/反解/默认 label 必须下沉到 shared 纯函数;平台 UI 只保留 token 编辑与展示,禁止再复制一份组合算法
快捷按键 modifier 是 one-shot 语义:buildTerminalShortcutSequence() 按 token 顺序消费 pending modifier,只修饰后面的第一个目标键,然后清空;例如 Shift + ← + a 编码为 \x1b[1;2D + a。不要恢复“有 modifier 只能一个目标键”的限制,也不要让平台 UI 自己实现 Shift/方向键规则。
Android WebView 若出现“sheet/表单看起来不能滚”,先不要凭截图猜高度;应先附着 webview_devtools_remote_<pid> 给目标滚动容器打 touchstart/touchmove/scrollTop probe,并用 adb logcat 验证 defaultPrevented 与 scrollTop 是否真实变化,再决定改事件捕获还是布局
Android IME / viewport / keyboard lift 计算只能有一个 helper 真源;页面层不得复制 resolveKeyboardLiftPx / viewport height 逻辑。若键盘弹起后出现 gap、内容缺失或 quickbar 错位,先确认 WebView 是 overlay 还是 adjustResize:已 resize 时用当前 viewport height 且 lift=0,overlay 时才用 stable height + lift。
IME 高度事件可能先于 OEM 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 顺序重放。
Android IME 容器上台只允许 UI shell 消费:TerminalPage 计算 terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell 用同一个 terminalImeLiftPx 上台;TerminalView 不接收 IME layout token、不触发 Android upstream onResize、不把 keyboard 高度写回 daemon/tmux。
Android Terminal quickbar 预留只能来自真实测量:terminalChromeBottomPx = measured quickBarHeight + safeOffset,terminalStageBottomPx = terminalChromeBottomPx + terminalImeLiftPx,QuickBar shell bottom = safeOffset + terminalImeLiftPx。禁止为了 IME 或默认态位置引入固定 render lift;也禁止 IME active 时只裁到 terminalImeLiftPx 而不预留 measured quickbar 高度,否则会遮挡终端内容。
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。
foreground 恢复不要无差别重连所有 session;默认先恢复 active session,其余只补非健康 session,避免 hidden tabs 被一起拉起放大带宽
foreground reconnect 若对同 host 多 session 走串行 bucket,必须把 active session 排在第一位;reconnect 成功后要立刻补一条 tail refresh request,但 hidden->active / foreground refresh 不要无脑 bootstrap 整个 tail :本地尾窗连续时只发带本地 revision/window 的 follow request,只有尾窗缺口或空 buffer 才 bootstrap;同时补一发 ping 做短超时 watchdog,避免“切回 tab 还是旧画面却迟迟不重连”
foreground / explicit resume 的 forceHead 不能被 active-reentry 去重 guard 吃掉:lastActiveReentryAtRef、connectedBaselineBurstGuardRef 只能抑制 passive active-reentry 重复 head;explicit-resume + forceHead 必须在同一 OPEN WebSocket 上发送 buffer-head-request,否则会出现“连接还在但后台返回/网络波动后界面不刷新”。 2026-05-13 新冻结:open-tab runtime switch 必须永远拆成两条语义:
restore-sync = 只恢复 local shell / active runtime,不开 transport
explicit-resume = 用户显式激活后才允许 resumeActiveSessionTransport
若 UI 只是把 active tab 切对了,就宣称 connected,这是假状态;transport freshness 真源只能留在 SessionContext
createSession(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。相关回归至少保留三条:
cold restore 不自动打开 daemon transport
explicit tab switch 触发 explicit-resume
foreground/active refresh 不得在 App 层长出第二套 transport reopen 语义
restored local shell 必须写入 transport identity;Home->返回后 /debug/runtime.transportSubscribers[0].id 不应变化,日志不得新增 missing-target / transport-detached / rebuild
历史教训:adaptive-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。
active + follow tab 不能只赌 tmux observer push;必须保留一个低频 tail probe (follow delta request + ping + 短 watchdog)作为漏通知自愈链路,否则会出现“终端实际在更新,但 UI 只有等本地输入/切换后才动”的假静止
same-socket buffer-head-request 超时不是 WebSocket 失败真相;只要 session socket 仍是 OPEN,activity/foreground owner 只能清 stale probe marker 并在同一 socket 继续请求 head,禁止调用 reconnectSession() 重建。
active transport freshness 不能等长 heartbeat timeout;active tab 若几秒内没有 server activity,SessionContext 必须先发 buffer-head-request probe。短等无响应只能继续同 socket probe / 显式暴露等待状态;除物理 close/error、target mismatch、missing/closed socket 的显式 open/resume 外,禁止强制替换 socket。UI 不得自行判断 timeout / 直接重连。
physical transport activity 与 terminal render freshness 必须分开:任意有效 frame 可更新 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。
daemon physical transport stale cleanup 必须严格晚于 Android target heartbeat 失败 contract。当前 Android 是 30 秒一个 target heartbeat、连续 3 次 miss 才判物理失败,因此 daemon bound 必须晚于 90 秒,当前取 190 秒;10 秒 cleanup 会在首个 client heartbeat 前释放 subscriber,制造“手机绿色连接且有流量,但正文和 remote-window catalog 都不刷新”的分裂真相。遇到该症状先对照 daemon /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。
terminal mux refactor 下,物理 WebSocket 与 session channel 是两层真相:reconnect/open 只能为同 target 创建一个 physical mux socket,并把所有待恢复 session channel 显式置为 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 断言。
terminal mux channel readiness 不能用 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 成功都不能算闭环。
drawer refresh / quick create / remote kill 这类 tmux management 不允许在已有匹配 Session/target transport 时新开 legacy 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 作为兼容路径。
terminal mux / transport accessor 新增时,不能只在 unit mock 或 facade 参数里接线;必须从 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/空屏”的分裂投影。
daemon 初始 buffer sync 不能发送无限全量大帧。若第一次 live sync 超过有界阈值,必须按 absolute row 连续切成同 revision 多个 buffer-sync,覆盖完整源 span,让 renderer 按帧组装拿到完整尾窗;禁止裁成 live tail 丢弃源 rows。这属于 daemon mirror reader 输出有界化,不允许改 tmux truth、client renderer 或 route fallback 补偿。
若 Android 端启用新的 terminal mux 协议,Mac daemon release artifact 也必须同步包含 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。
Auto route selection 不再消费旧的 saved 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。
若 daemon 代码已更新但 ~/.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/kickstart
buffer manager 不允许直接把 renderer 切回 follow;它只能更新本地 buffer/head 并通知 renderer。renderer 只允许因 重新进入 / 下滚到底 / 用户输入 退出 reading
Android renderer 新冻结:唯一状态是 renderBottomIndex;renderTopIndex 只能派生,reading/follow 都只改 bottom pointer,renderer 不得参与 buffer 生产或把 producer bottom 写回 source
active tab 的 follow 三屏窗口允许存在 gap;TerminalView 不能因 visible/precheck window 不连续而冻结上一帧,必须先渲染最新 tail + 空白 gap 占位;follow 态禁止 prefetch/request 补洞 ,只等 live tail 或显式切到 reading
active 页的 gap repair 只针对 reading 态当前三屏窗口命中的缺口;不要从旧 stop point 连续追到最新,窗口外内容允许保持不连续以控制带宽
reading 贴近缓存顶部时,3 屏只是 cache window,不是滚动上限;要先预取前两屏并显示 loading,再继续上滚,不能把顶部卡成固定三屏
client 本地 cache window 必须围绕当前 reading viewport 动态移动;禁止 trim 时永远只保最新 tail,否则向前补到的历史会被立刻愚蠢扔掉
terminal 主题切换的真源是“默认前景/背景 + ANSI 16 色 preset”,不是只换容器背景;主题 id 应持久化到 shared BridgeSettings,Settings 只做 preset 选择
Android / Mac 若都要支持 terminal 主题,preset 与颜色算法必须下沉到 shared 纯模块,平台 TerminalView 只消费同一份 preset,避免 ANSI 映射再次分叉
若 Settings UI 把主题卡片标成“正在使用/Active”,点击卡片就必须立即写入真实持久化存储;不能只停留在本页 draft,否则用户切出去再回来会恢复默认主题,属于典型假状态
BridgeSettings 里的 terminalWidthMode 是启动排版唯一真源;storage hook 首次 render 必须同步读取 localStorage 并 normalize,禁止先返回默认 mirror-fixed 再等 effect 修正,否则 restore/connect 首帧会按错误宽度模式连接。
Settings 保存 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。
Session runtime 里的 requestedTerminalGeometry 只允许保存 measured cols 事实,不允许把历史 widthMode 反过来覆盖当前 BridgeSettings.terminalWidthMode。connect/reconnect/open payload 的 width policy 必须以当前 BridgeSettings 为准;否则用户从 fixed 切到 adaptive 后,旧 session geometry 会继续发 mirror-fixed。
adaptive width 问题不能只查 Android APK: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,否则不要在启动时自动清理。
daemon 不能为了移动端 mirror 修改用户 tmux 的 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,不是在客户端补偿。
daemon 可以请求 tmux 执行真实用户操作(例如 input、create/kill/rename session);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 渲染。
终端 viewport 周围不能画 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。
daemon live diff 的 buffer-sync wire payload 必须覆盖首个 changed range 到最后 changed range 之间的完整连续 authoritative span;禁止只发送不相邻变更行并让 client 保留中间旧行,否则快速 TUI 输出会出现旧 buffer 闪回。若需要降带宽,必须先设计多 range wire contract 和 client apply 红测,不能把有洞 payload 伪装成完整窗口。
client same-revision 覆盖保护只能拦截无请求来源的旧包;foreground/reconnect/input 后由 renderer/reading 显式发出的 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”。
若当前 repo 是 fork runtime 真源,发布 npm 时必须直接发布 本 fork 源码编译产物 ;禁止通过 wrapper / alias / “套一层别人已发布包” 来冒充 fork 发布,这会破坏后续升级与维护链路
remote-window <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 连接成功证据。
remote-window pending/unplayed wallpaper 不能用无 alpha 的 app logo 再靠 mix-blend-mode 遮白底;Android WebView 可能仍显示方形卡片。必须用实际透明 alpha 的 engraved logo asset,测试锁住不含大字、不依赖 blend mode。
remote-window stream-start timeout 分层:catalog timeout、stream-start request timeout、receiver track timeout 是三个 owner。stream-start request timeout 必须长于 daemon ScreenCaptureKit 首帧等待 + input helper warm + 控制面预算;receiver track timeout 只能在收到 daemon remote-window-stream-started 并应用 answer 后启动。否则手机会看到误导性的 Remote window stream start timed out。
remote-window 输入回传必须走 remote-window-input 显式协议和 daemon injectInput owner;Android 只按 manifest crop 归一化 click/gesture/key intent,不能在 UI 做 macOS 坐标真源或假注入成功。
remote-window target-locked 浮窗必须消费 Android IME 的 bottomInsetPx,不能只抬入口悬浮标;fullscreen 继续只服从 safe-area。远程 IME committed text 保持原始 CJK/特殊字符/换行,不走 terminal punctuation/newline normalization。
remote-window fullscreen 在 IME 打开时也要消费 bottomInsetPx 作为 overlay padding,并允许本地 letterbox pan 避开键盘遮挡;这只是 Android projection,不得改变 page shell layout、daemon capture/crop、Mac 坐标、tmux 宽度或 terminal renderer。
remote-window 单指滚动在 floating、1x fullscreen、zoomed fullscreen 都是 pointer-move 阶段的 realtime pixel 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。
generic app 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 后真实滚动”的黑盒。
generic app 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 截屏交付最低 gate:Android overlay/button 测试证明点击截图不发送 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。
remote-window / daemon-side 代码改动后,APK 版本号变化不代表 Mac daemon 已加载新输入逻辑。必须 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 会看到“没有任何变化”。
remote-window 输入交付最低 live gate 是运行中 daemon/WebRTC 对受控 AppKit 窗口启动真实 app-window stream,以 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 闭环。
remote-window live probe 的 run id 必须包含 timestamp + pid + random,禁止只用 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。
remote-window 手机侧必须有 page-level gate:真实 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 通过。
Android ImeAnchorPlugin 是 terminal 和 remote-window 共用软键盘 owner。默认必须 setShowSoftInputOnFocus(false),但显式键盘按钮触发 show() 时要随 anchor enabled 同步允许 soft-input-on-focus;hide/blur/editor-active 再禁用。现场验证不能只看 showSoftInput() 返回 true 或 anchor hasFocus,必须看 keyboardVisible/keyboardHeight 或真机截图有可见软键盘。
remote-window installed-phone 手势/IME L5 不能只看 React 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 只定位,不是闭环证据。
remote-window 真机仍报“不可操作”但无在线 ADB 时,先把 metadata-only 输入诊断投到 状态浮窗: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 注入。
daemon remote-window continuous stale 真源只能是首次 daemon-local receive time,永不因 focus/前序成功刷新;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()
Android WebView 的 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 && center
session group layout axis 默认按 aspect ratio:窄竖屏(当前阈值 width / height <= 0.4)强制 vertical,上下滚;宽竖屏默认 horizontal,但 Settings 可切 vertical;landscape 永远 horizontal。这个设置只影响 layout projection,禁止改写 drawer 固定槽位、session/tab/pane 真相。
看到“切换 session 后状态已 connected,但真实 shell 画面不刷新/仍是旧内容”时,先跑 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。
横向 session group side peek 的身份显示必须避开 status bar / 返回按钮:不要把 session 名贴顶部;应放在中部安全区,session 名和 host 至少允许两行,以保证窄侧边仍能识别目标。
多 daemon / 多服务器 UI 身份必须走 src/lib/server-identity.ts:用户可见 label 优先是 connection/daemon 名,颜色也按同一 server key;禁止在 drawer、side peek、tab 文案里把 bridgeHost:bridgePort 或 telnet/bridge 端口当服务器名。
如果 Relay directory 和 direct/Tailscale 历史同时存在,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 需要测试锁住不同色。
traversal route health cache 是进程级全局状态;会创建 TraversalSocket 或断言 WebSocket 实例/线路选择的测试必须在 beforeEach 清 defaultTraversalRouteHealthCache,否则前一用例记录的坏线路会让后续用例不创建 socket,表现为 MockWebSocket.instances 为 0。