corral 维护指南

September 5, 2026 · View on GitHub

标题与排序

  • 最近会话排序优先使用历史文件更新时间。用户对“最近”的直觉是最近被续接或写入,而不是文件内部最后一条可解析消息。
  • 文件时间不是绝对可信,且污染粒度可以细到单个文件,不一定成批出现:Claude Code 在会话驻留/被重新打开时会追加没有时间戳的元数据条目(last-promptai-titlemodepermission-mode),把文件 mtime 顶到“现在”而不产生任何新对话内容;Syncthing、复制、批量元数据刷新是同一类问题的批量版本。修正逻辑统一收在 models.pyeffective_session_time(file_mtime, event_time):当 mtime 比会话内部最后一条真实事件新出 1 小时以上的 gap,就判定 mtime 不可信,逐会话回退到 event_time;两个扫描器的 _build_session_info 都在返回结果前调用它写回 mtime/display_time/time_source。曾经按“同一分钟桶 ≥5 个会话”识别批量污染簇的启发式已废弃——它只覆盖批量场景,漏过了本节描述的单文件被驻留进程 touch 的情形(真实故障:两个会话被 touch 到不同分钟,各自没能凑够聚簇阈值,在列表里显示成"20分钟前",实际是 9-11 天前的会话)。
  • Claude Code 自带 aiTitle 不稳定,只能作为临时兜底的最后来源,不能绕过生成缓存直接展示。
  • 无缓存时必须先生成本地短标题,再交给后台模型优化。首屏不能依赖后台生成器(claude/codex 无头调用)是否及时返回。
  • 生成标题的语言跟那场会话里用户提问的主语言,不跟界面语言,也不默认中文。【裁定·2026-08-30】标题是列表里用来辨认「那次任务」的内容,不是按钮/底栏那种界面文案。判定只看用户侧提问(扫描器选出的最佳意图、首条/末条用户消息),不看助手回复(助手可能被项目说明要求用另一种语言)。中英夹杂时跟占比更高的那一侧;看不出主语言时才回退界面语言。不要做成「界面是中文就全部译成中文」,也不要做成「英文会话也可以用中文描述,但优先简洁中文」——后一句是 2026-07-18 起写在生成说明里的旧偏好,已从生成说明删除。同类产品(Claude Code)用户反复要求标题跟对话语言走,官方后来也按这个口径改。改生成说明时必须保留噪音过滤用的固定开头(PROMPT_MARKER),改了扫描器就认不出自产会话。成功写入缓存的标题默认不因语言规则变更而重跑;要翻旧标题必须显式抬高缓存版本,那会再花账号额度。占位「待生成标题」和斜杠命令临时标签(如文档初始化)才是界面文案,走多语言表。
  • Claude 标题生成必须使用 --no-session-persistence,从源头禁止一次性标题请求写入 Claude 会话历史;扫描侧仍须过滤历史版本已落盘的自产标题 prompt 和只有低价值消息的记录,避免旧噪音反过来进入列表。
  • 标题生成以 5 条会话为一批,最多 5 路并发。自动模式每次从随机助手开始,批次间依次轮转,让本机可用的 Claude、Codex、OpenCode、Kimi、Cursor、Pi 平均分担;轮到的助手失败、超时或返回无有效标题时,仅在该批依次交给其余助手接管,并且后续批次不再重复调用已失败的助手。每次后端调用仍以 90 秒为上限。每批完成立即原子写缓存,界面可陆续显示结果。生成出的有效标题一旦写入缓存即为该会话的固定标题,后续对话内容增长不能让它再次排队。会话只有“在吗”等无任务信息时保留本地标题且不调用模型。
  • 标题生成的失败是带冷却的终态,不是“永远不再试”,也不是“下次启动立刻再试”:调用失败、超时、不可解析/低价值/机器 slug、批量结果部分缺项,以及本机没有任何可用生成器时,都给受影响会话写入当前 TITLE_CACHE_VERSIONgeneration_state=failedfailed_at,保留本地兜底并立即清掉 generating 状态。冷却期内(默认 6 小时)同一缓存版本不再自动提交模型,避免瞬时故障反复排队花额度;冷却过期或历史失败条目缺少 failed_at 时允许再入队。提升缓存版本后失败标记也会自然失效。成功、失败和部分缺项都要逐批 save_cache,不能只保存成功项,否则缺项会永远重新排队。
  • 标题生成后端已抽象为 titlegen.pyTitleGenerator,覆盖与默认运行时注册表一致的六家:claude / codex / opencode / kimi / cursor / pi。titles.py 只负责批量 prompt、JSON 解析和缓存,不感知具体 CLI;新增生成器只在 titlegen.py 加实现并注册进 _GENERATORS(候选集合与 default_registry 对齐),禁止在 titles.py 里写 subprocess 调用,也禁止 titlegen import runtime/。Pi 必须用 pi --approve --no-session --no-tools --print <prompt>:不落盘会话、不调用工具、只输出结果。除非用户明确设置 CORRAL_TITLE_GENERATOR(旧名 SC_TITLE_GENERATOR)指定所用助手,自动模式不得设默认优先级;未设置 CORRAL_TITLE_MODEL(旧名 SC_TITLE_MODEL)时,标题生成必须继承对应助手的全局默认模型;该变量仅用于用户明确指定标题生成的模型。缓存与生成器无关,换生成器不重算已有成功标题;冷却期内也不绕过当前缓存版本的失败终态。
  • 真实冒烟时某家 CLI 因本机账号 / 模型列表刷新 / 网络失败,不算 corral 缺陷:验收口径是「该助手失败后,本批其余可用助手能顶上,且成功标题可解析」。例如本机 Codex 因模型管理器刷新超时失败、Claude/OpenCode/Cursor 仍能出标题,属于环境侧问题;不要为绕过某家账号限制去改变自动模式的平权轮转,除非用户明确选择固定某个助手。
  • 自产噪音会话的过滤,每个可能被生成器落盘的运行时扫描器都要有:Claude 用 --no-session-persistence、Codex 用 --ephemeral 尽量不落盘,扫描侧仍须按标题请求标记的任意出现位置兜底过滤;OpenCode 会把整段请求额外包一层引号,不能只判断开头。OpenCode 官方没有 ephemeral:run --auto 必须放到临时 OPENCODE_DATA_DIR 并加 --dir(登录凭证从用户数据目录拷进临时目录),否则会写入用户的共享库,一次性任务变成侧边栏新卡、滤掉后又消失,表现为列表自己乱跳。Kimi(-p)、Cursor(agent -p)每次调用仍会落盘,扫描侧必须过滤首条用户消息 / 回退标题 / 原生标题,漏掉会让标题生成会话刷屏列表。
  • titles.save_cache 是原子写(临时文件 + os.replace),不是直接覆写:后台标题生成进程逐批写、TUI 每约 1 秒轮询读同一份 titles.json;直接 open(..., "w") 覆写会被并发读到半截 JSON(load_cache 解析失败静默退回 {},界面标题短暂回退临时兜底)。并行批次落盘时必须对共享 cache 字典加锁后再 save_cache。改这个函数前确认没有退回裸覆写。

标题生成进程

  • 标题生成必须由脱离当前终端的独立进程承载,不能放回 TUI 进程内线程。
  • execute_launch 会用 os.execvp 替换当前进程;按 Esc 退出也会结束 TUI 进程。TUI 内线程会在这些路径上丢失未完成标题。
  • 当前模型是:_spawn_title_daemon 拉起 corral --generate-titles 后台进程,后台进程用缓存目录下的文件锁保证全机单实例;TUI 侧只读缓存并轮询缓存文件变化。
  • 界面运行中也会按需再拉后台SessionStore 合并扫描或轮询缓存后若 generating 非空,经短防抖调用注入的 _title_spawn_fn(生产路径赋值为 _spawn_title_daemon)。只靠启动时 spawn 一次会漏掉运行期间新出现的会话。撞锁时新进程立即退出,不重复烧额度;generating 仍非空且缓存长时间无变化时也会再请求一次,兜住上一轮 daemon 已死的窗口。
  • 后台进程在持锁期间最多 drain 若干轮(扫 pending → 生成 → 再扫),避免生成耗时期间新冒出的会话因「只扫一次就退出」被永久漏掉。
  • 后台进程内的候选生成器选择发生在 refresh_titles;本机一个 agent CLI 都没有时保留临时兜底标题,并把本批会话写成当前缓存版本的失败终态,不能静默返回后让会话永远留在 generating、下次启动再次排队。不要在 TUI 首屏路径做可用性探测。

跨扫描器共享 helper(scan/common.py)

六个扫描器(scan/claude.py/scan/codex.py/scan/opencode.py/scan/kimi.py/scan/cursor.py/scan/pi.py)互不 依赖运行时私有格式,但都需要几个完全相同的小工具:shorten_cwd(路径展示时 把用户主目录替换成 ~)、parse_timestamp(ISO8601 字符串转 epoch 秒)、 live_processes / live_pids_by_process_name(存活同名进程及其 cwd)、 process_command_line / process_environ / open_file_paths(读命令行、环境变量、 打开的文件路径,供 Cursor 等按正向证据精确绑会话)。这些集中在 scan/common.py, 这个模块集中跨扫描器共用的探测与缓存(cwd / 命令行 / 环境按 pid 集合记忆), 避免多份重复实现各自演进出细微差异;新增运行时扫描器时优先检查这里有没有能复用的 helper,不要先照抄再改。运行时私有的解析格式(JSONL 字段、SQLite 表结构等) 仍留在各自的 scan/*.py 里。

Claude 扫描

  • Claude 的 aiTitle 不一定在第一条用户消息之前出现,扫描头部不能拿到工作目录和首条用户消息后立即停止。
  • Claude 的 /plan 等本地命令会把真实需求放在 <command-args>...</command-args> 中,这类内容必须提取为用户意图。
  • Claude 的兜底标题必须走候选评分。短续接词、催促、系统提示、错误消息和自产标题 prompt 都是低价值候选;用户侧全是低价值消息时,才允许用最后助手摘要兜底。
  • Claude 侧通过 tail 消息里的 [Request interrupted by user] 精确字符串识别中断,不要用宽松关键词匹配。
  • titles.py_compact_title 里正则使用原始字符串。写 \s\S\w\d\n 时不要多打一层反斜杠;改这个函数前先用真实会话文本验证输出是完整可读片段。
  • load_conversation(会话预览的正文来源)不能按 stop_reason 过滤要不要展示某条 assistant 文本。 真实 JSONL 里一次 assistant 轮次的 thinking/text/tool_use 各是独立的顶层行,且共享同一个 stop_reason——哪怕这一行本身就是纯文本、只是后面紧跟着一次工具调用,它的 stop_reason 也是 tool_use 而不是 end_turn。之前的实现按 stop_reason in (None, "end_turn") 过滤 assistant 行,实测在一个 97 条真实消息的会话里把 88 条有真实文本内容的 assistant 消息整段丢了(包括工具调用前的说明、AskUserQuestion 之前的分析文字等),预览页看起来像是"Claude 跳过了回答",其实是解析漏了。现在只要 content 里有非空 text block 就展示,不再看 stop_reasonstop_reason is None 分支保留给可能存在的历史遗留流式格式(增量快照,只在 flush 前取最后一份),和当前主流格式互不冲突。改这块逻辑前先用真实会话文件跑一遍 load_conversation 数消息条数,不要只信单测里手写的小样例。
  • Monitor/task-notification 等系统注入事件在原始 JSONL 里也挂在 type: "user" 轮次下,load_conversation 必须整条丢弃,不能当成真人输入。 区分信号是顶层 origin.kind 字段:真人手动输入是 "human"(或字段缺失,如老格式//plan 等本地命令包装出的用户轮次),系统事件(Monitor 到点触发、task 通知)是 "task-notification"。消息历史只保留 Agent 和真人的对话,系统事件价值很低——最初的实现把它标成 ConversationMessage("system", ...) 单独渲染成"◇ 系统事件"展示出来,被用户否决("什么系统消息都不要显示出来"),改成命中 origin.kind not in (None, "human") 就直接 continue 跳过,不进入返回结果。ConversationMessage.role 因此保持只有 user/assistant 两种,不要再引入第三种 role。新增其它 origin.kind 取值(目前本机全量历史只出现过 human/task-notification 两种)时按同一分类原则处理,不要默认归到 user
  • content part 的 text 字段、snapshot/payload 这类嵌套对象字段,同样可能是 JSON null(key 存在但值为 null),不是只有 Codex 才有这个坑。 _extract_text_entry_timesnapshot 取值、_build_session_info 尾部循环取 assistant 文本、load_conversationtext_parts 列表推导式,都曾用裸 part.get("text", "")/entry.get("snapshot", {}) 取值——这类写法的默认值只在 key 缺失时生效,key 存在但值为 null 时会拿到 None,后续 .strip()/.get(...) 直接 AttributeError 崩掉整个扫描或预览(真实场景可复现,不是假设)。统一改成 (x.get(key) or 默认值) 的写法;新增任何从 Claude JSONL 嵌套字段取值的代码都要假设该字段可能是显式 null,不能只防"key 缺失"这一种情况。
  • Codex 的 event_msg.payload 里字段值可能是 JSON null(key 存在但值为 null),payload.get(key, "") 的默认值只在 key 缺失时生效,取不到 null 场景,会拿到 None 再被 str() 变成字面量 "None" 混进正文。 实测 task_complete.last_agent_message 为 null 很常见(任务结束但没有最终文本输出,比如被打断/答案已在更早轮次说完),预览页因此显示过多轮" ◆ Codex\nNone"。三处取值(user_message.messageagent_message.messagetask_complete.last_agent_message)统一改成 payload.get(key) or ""or 会把 None 也兜成空字符串再被后续的 if text: 过滤掉。改 scan/codex.py 任何从 payload 取文本的地方都要用这个写法,不要用 .get(key, "")

Codex 扫描

  • 新版 Codex 的对话不再只写旧事件流。 真实用户输入和助手答复也会出现在响应记录中,首轮还会带入大段运行环境说明;扫描、标题摘录和右栏预览都必须同时读取两种形态,并跳过这类注入说明,继续找到真实任务文本。否则有真实工作的会话会被误判为「Codex 新会话」,标题服务把空摘录缓存成「新会话 / 空会话」后不再重试。此类泛标题必须视为无效缓存,恢复真实摘录后重新补齐;回归同时覆盖列表项、完整预览和旧缓存重试。
  • 同一句真人输入会各写一遍 response_itemrole=user)和 event_msg.user_message load_conversation 必须像助手侧那样按相邻正文去重,只留先到的那条(时间戳也跟它走)。漏了这一步,右栏预览和 Your prompts 会成对出现同一句;本机抽查约 80 条会话里 79 条都有这种相邻双写。不相邻的同一句是两轮真实提问,不要跨轮折叠。回归:test_codex_conversation_dedupes_response_item_and_event_msg_user
  • Codex 自身的多智能体(swarm/subagent)任务会把每个子代理线程各自写成一份独立的 rollout-*.jsonl 文件,扫描时必须过滤掉,不能当作用户发起的顶层会话列出。 真实故障:一个真实会话(session_meta.payload.thread_source 缺失或为 "user")执行多智能体任务时,会派生出好几个子代理线程(thread_source: "subagent"forked_from_id/parent_thread_id 指向父会话,agent_nickname 是 Codex 随机取的代号),这些子代理线程 fork 时继承了父会话开头的历史,因此它们文件里"第一条用户消息"(也就是列表兜底标题的来源)和父会话完全相同。_find_all_session_files~/.codex/sessions/ 下所有 .jsonl 一视同仁地扫描,没读 thread_source 字段时,这些子代理文件会和父会话一起出现在列表里,表现为同一段任务描述反复出现好几条、目录和时间都很接近,用户会误以为是"同一个会话被重复列出"的 bug,实际是把 Codex 内部子任务线程误当成了独立会话。修法是在 _build_session_infosession_meta 时顺带取 payload.get("thread_source")scan_sessionsthread_source == "subagent" 直接 continue 跳过,和过滤空会话、死 cwd 会话放在同一批前置检查里。
  • 判活(_live_session_ids)曾对每个存活 codex 进程各发一次 lsof -p,是首屏超过 1s 硬指标的真实根因:本机实测单次 lsof -p <pid> 耗时约 500ms,2 个 codex 进程就吃掉近 1 秒,进程越多越慢。改为按平台分流:Linux 直接遍历 /proc/<pid>/fd 逐个 os.readlinkrollout- 文件(近乎零成本,不 fork 子进程);macOS 等无 /proc 的平台改为一次合并调用 lsof -n -P -Fpn -p <pid1>,<pid2>,... 覆盖全部候选 pid(-n -P 跳过 DNS/端口名解析——这是原实现单次 lsof -p 慢的另一诱因;-Fpn 只输出 p<pid>/n<name> 两类字段行,逐行按 p 切换当前 pid、遇到含 rollout-n 行才抽 UUID)。两条路径都不判断 fd 是读还是写模式,与改动前 lsof 实现的实际行为一致(旧实现同样没有过滤 w 模式,只要 rollout- 出现在 lsof 输出里就算命中)。

OpenCode 扫描

  • 历史存储是 SQLite(~/.local/share/opencode/opencode.db),不是 JSONL 文件session/message/part 三张表,正文只在 part.data 的 JSON text 字段(message.data 只有角色/时间/finish 等元数据)。OpenCode v1.2.0 起才是这个格式,更早版本的纯 JSON 文件存储不做兼容——官方升级会自动迁移到 SQLite,遗留在老格式的用户极少;本机没有 opencode.db 时这个运行时的会话列表就是空的,不报错、不尝试读旧格式。
  • 只读连接,WAL 库可能拒绝只读打开_connect_rosqlite3.connect("file:<path>?mode=ro", uri=True, timeout=0.5)。opencode 正在写库(WAL 模式)时,极端情况下(需要 checkpoint 恢复且无活跃写者)只读打开可能失败;单个数据库连接/查询失败时继续扫描其它数据目录,至少一个数据库成功(即使确实查到 0 条)就正常返回。若已发现数据库路径但全部连接/查询失败,必须抛中文 RuntimeError 给 registry 保留上一次成功结果,不能把瞬时故障伪装成“所有会话被删除”;本机确实没有数据库路径时仍正常返回空列表。timeout=0.5 把单库 busy 等待封顶在 0.5s。
  • 一条 SQL 超额取候选,含四个预览子查询:过滤 parent_id IS NULL(子代理会话)和 time_archived IS NULL(已归档),按 time_updated DESC 排序;四个关联子查询分别取最后一条消息(推状态用)、首/末条用户文本、末条助手文本、SUM(LENGTH(part.data))size_bytes(标题缓存失效 key,必须随内容增长,不能用固定值)。SQL LIMIT 必须大于界面条数max(limit * 8, 200)):标题生成噪音按更新时间排在最前,等于展示条数时会把真实会话挤出窗口,列表成员进进出出,侧边栏自己乱跳(2026-08-16 真机:top-50 里 45 条是标题生成一次性任务,只剩 5 条真会话)。Python 侧再按 PROMPT_MARKER 过滤首条用户消息 / 回退标题 / 原生标题后截到 limit。实测超额查询仍远在首屏预算内,无需再加 Codex 那种"凑够 limit 提前停止"的优化。
  • 状态推导没有显式中断信号:OpenCode 不像 Codex 有 turn_aborted 这种明确事件,末轮 finish 只有 stop/tool-calls/缺失/未知几种取值;只有消息带非空 error 字段才判"已中断",finish=="tool-calls" 或其它值一律归"无状态"(宁缺毋滥),不要臆测把 tool-calls 当成中断或完成。
  • 判活禁止「同目录只留最新一条」:同一工作目录常会同时跑多个 OpenCode TUI。正向证据优先:命令行 -s / --session,托管注入的完整 CORRAL_SESSION_ID(8 位占位 ident 不得前缀猜测),再才是 --continue 或「进程启动 ≤ 会话创建」一对一认领。opencode run / serve / session 等子命令不算 TUI。--prompt 后的接力说明不当命令行(取值旗标跳一词不够,空格拼接后 session 会把仍在跑的交互窗口判成已结束);细则与验收陷阱见 会话扫描知识库 §6。旧实现会把仍在跑的会话标成已结束,点进去变成历史消息预览(2026-08-16 真机:主目录同时 4 路,带 -s 的恢复会话 live=false;同日从 Pi 接到 OpenCode 的 --prompt 会话全部被 session 一词误判)。标题生成必须把 run 丢进临时数据目录。判活失败静默降级为空集。回归:OpenCodeScanTests.test_live_flags_*
  • 放行参数是 --auto,且位置敏感;auto_approve_args 之外还必须覆写 compose_passthrough_argv(2026-08-04 在 opencode 1.18.9 上实测重定):--auto(自动批准所有未被显式拒绝的权限请求)如今在主命令(TUI)和 run 子命令的 --help 里都是正式声明的选项,旧的隐藏别名 --yolo / --dangerously-skip-permissions 被折叠进同一个开关(二进制里可见 auto: D.auto || D.yolo || D["dangerously-skip-permissions"])。因此 OpenCodeRuntime.auto_approve_args 已改为 ("--auto",),恢复 / 接力 / 空白新建 / 续接四条路径与 claude/codex 一样统一垫上,之前记录的"OpenCode 没有可用放行参数"的能力差距不再存在。两条实测得来、写反了就会静默改变用户命令的规则:
    1. --auto 必须排在子命令之后。主命令的第一个位置参数是项目路径,--auto 一旦前置,后面的词就不再被当作子命令:opencode --auto stats 实测报 Failed to change directory to <cwd>/stats,也就是"在名为 stats 的目录里开 TUI"——不是报错,是静默执行了另一件事,比报错更难发现。
    2. 只有主命令和 run 认这个参数stats/export/auth 等子命令带上它会被 yargs 严格校验判为未知参数、用法错误退出(exit=1)。 所以直启透传不能沿用"垫在最前"的默认实现,OpenCodeRuntime.compose_passthrough_argv 按"首个参数是 run → 插在它后面;是其它子命令 → 一律不垫;是路径 / flag / 没有参数 → 按主命令前置"分流。回归:tests/test_runtime.pytest_opencode_passthrough_* 四条。历史教训仍然成立:以本机实测行为为准,不要以文档为准——1.15.11 时期官网已在写 --auto,而本机两种写法都报错退出;这次是反过来,升级后文档与实测终于一致。旧版本不再做降级兼容(机主 2026-08-04 拍板:不认 --auto 就是该升级 opencode)。

Kimi 扫描

  • 历史按「工作区 / 会话」两级目录存放,不是单文件~/.kimi-code/sessions/<workspace_id>/<session_id>/,元数据在 state.jsontitleisCustomTitleworkDirlastPromptcreatedAt/updatedAt),对话流水在 agents/main/wire.jsonl。子 agent 的 agents/<other>/wire.jsonl 是旁路对话,扫描和预览一律只读 main,忽略其它 agent。元数据优先取 state.json(小而权威,updatedAt 直接作会话时间);正文只能解析 wire.jsonl。本机没有 ~/.kimi-code/sessions/ 时该运行时会话列表为空,不报错。
  • wire.jsonl 是协议事件流,混着体量很大的噪音行:开头的 config.update(系统提示,约 20KB)、llm.tools_snapshot(工具定义,可上百 KB)、llm.request/usage.record 等都与对话正文无关。逐行 json.loads 会很慢,scan.kimi._iter_message_entries 先按带引号的类型值子串("context.append_message" / "context.append_loop_event")廉价过滤,只对真正承载对话的两类事件行做完整解析。用带引号的类型值而不是 "type":"…" 前缀做匹配,是为了兼容紧凑与带空格两种 JSON 写法(真实 wire 是紧凑的,但测试 fixture 用默认 json.dumps 带空格)。
  • 用户 / 助手正文分别来自两类事件:用户消息是 type=="context.append_message"message.role=="user",正文在 message.contenttype=="text" 的分片;message.origin.kind"user"(如 task-notification 等系统注入事件)一律丢弃,和 Claude 的 origin.kind 过滤同思路。助手正文是 type=="context.append_loop_event"event.type=="content.part"event.part.type=="text"part.type=="think" 是思考过程,跳过。同一轮里连续的文本分片合并成一条助手消息,遇到下一条用户消息断开成新一轮。(turn.prompt 事件与 context.append_message 冗余,不解析,避免用户消息重复。)
  • 状态推导按末轮角色:解析到的最后一条消息是用户 → ⏳待回复,是助手 → ✅已完成,都没有 → 无状态。Kimi 的 step.end.finishReason 里虽然可能有中断信号,但格式尚未在真实数据里充分观察,暂不细分「已中断」,宁缺毋滥(和 OpenCode 一致)。
  • 判活禁止「同目录只留最新一条」:同一工作目录常会同时跑多个 Kimi TUI。正向证据优先:命令行 -S / --session,托管注入的完整 CORRAL_SESSION_ID(8 位占位 ident 不得前缀猜测),再才是 --continue / -c 或「进程启动 ≤ 会话创建」一对一认领。进程 comm 是 kimi-code 不是 kimi-p 打印模式与 server / web 等子命令不算 TUI。旧实现按 cwd 折叠后只标最新一条,会把仍在跑的会话标成已结束,多个新建还会把 pid 错绑到别人的历史上。判活失败静默降级为空集。回归:KimiScanTests.test_live_flags_*
  • 接力到 Kimi 只能走非交互模式(相对 claude/codex 的已知能力差距):Kimi 的 -p/--prompt 是「跑一个 prompt 并打印,跑完退出」的 headless 模式;根命令不接受位置参数形式的初始 prompt(带位置参数会报 unknown command),交互式 TUI 也没有从命令行预置首条消息的入口(实测 dist 里根 action 是 opts.prompt !== void 0 ? "run prompt" : "start shell",二选一)。因此 KimiRuntime.build_new_plan(跨运行时接力读别家历史新建 Kimi 会话)只能用 kimi --add-dir <源历史目录> -y -p <接力提示词>:Kimi 读原始历史、把最后一个未完成任务跑完并打印结果后退出,用户随后可用 kimi -c(continue previous session for working dir)在同一会话上继续交互。同运行时原生恢复(kimi -y -S <sessionId>)和空白新会话(kimi -y)不受此限。Kimi 作为接力(被别家读取)完全正常:export_handoff 指向 wire.jsonlhistory_reading_hint 说明上面的格式。未来 Kimi 若新增交互式预置 prompt 的入口,应把 build_new_plan 切成交互式,与 claude/codex 对齐。
  • -y/--yolo 在根命令即生效:不像 OpenCode 的危险参数只在子命令下可用,Kimi 的 -y 主命令直接接受,所以正常放进 KimiRuntime.auto_approve_argscorral kimi 裸直启会自动垫上,与 claude/codex 一致。

Pi 扫描与启动

  • Pi 历史位于 ~/.pi/agent/sessions/ 的 JSONL。首行必须是 session header;其后的 message entry 可以形成分叉树。v2+ 扫描和预览都只能从最新叶子沿 parentId 回溯后再正向展示,禁止平铺文件里全部 message,否则已经分叉出去的旧对话会重新出现在用户当前会话里。v1 jsonl 的 message 没有 id/parentId:按文件顺序读取,不能当成空会话丢掉(Pi 自己加载时才 migrate 成 v2,corral 只读看不到那次落盘)。
  • AI 易错点【必须】scan_sessionslimit 对默认 cwd 堆和 corral-<ident>/ 隔离目录各算一份。v0.24.139 起托管写入隔离目录,mtime 最新;若仍按全树凑满 limit 就停,侧栏会只剩最近的 Pi、历史堆被挤掉(2026-08-20 工作电脑)。不要为了「返回条数严格等于 limit」把两套目录混在一个配额里。置顶/分组成员经 keep_ids 再豁免:侧边栏记忆里的会话即使 mtime 排在配额外也必须出现在列表;项目筛选救不回没扫到的卡。列表身份 = jsonl header id。回归:test_isolation_dir_sessions_do_not_starve_heap_historytest_keep_ids_survive_scan_limit
  • Pi 的可展示正文只取 user/assistant 的 text 分片;thinking 与工具分片一律不进入标题摘录、状态判定和对话预览。最后可展示角色是 user 时为待回复,是 assistant 时为已完成;没有真实 user 文本的记录不进列表。
  • Pi 同运行时恢复用 pi --approve --session <历史文件>,复制会话用 pi --approve --fork <历史文件>;跨助手接力和空白新建同样带 --approve。接力只能读取原始 JSONL,不得修改原会话或把其内容重写成新文件。
  • v0.24.146 起托管路径只为新建/分叉钉 --session-id <ident>,恢复走找不到即失败的 --session <path>;不再注入 --session-dir,并显式清空从旧父进程继承的 PI_CODING_AGENT_SESSION_DIR 会话写回 Pi 默认项目目录,原生 /resume 当前项目与 All 恢复完整。全局 corral-session-identity 扩展由 Corral wheel 自带并在启动 Pi 前幂等安装,只在 ctx.mode === "tui" 时写本机 claim,不读取正文、不联网;scanner 按 claim 的精确 session id/path 绑定 pane,subagent 的 SDK 子会话不参与。恢复前后使用按 canonical session path 哈希的本地 ownership lock,重复 writer 在 prompt 进入模型前被阻止。完整协议见 Pi 会话身份扩展设计;扫描如何消费 claim 见 会话扫描知识库 §2.2.1
  • AI 易错点【禁止】为了消掉 Warning: No project session found with id '<ident>' 去掉 --session-id。Pi ≥0.80.4(#6407)把 --session-id 解释成「先按这个 id 恢复已有 project session」,找不到就打这行黄字告警,随后照样用该 id 新建会话——后半句才是实际结果,功能不受影响。托管新建每次都是新的 8 位 ident,所以 shim 拦到的裸 picorral pi 直启、界面里新建会话每次都会出现这行,属预期、无害。拆掉 --session-id 会让落盘 id 与占位卡 ident 不同,分屏组丢成员、组外冒出重复卡(见上一条)。原生恢复走 --session <历史文件>、不带 --session-id,因此没有这行;用户不想被托管、也不想看到它时,用 command pi 绕过 shim。
  • 判活(scan.pi._apply_live_flags)只消费有效 claim,不要再按打开的 jsonl / 隔离目录最新文件 / 启动时间配对去猜属主。没有有效 claim 时保持占位或未绑定。其它既有边界仍成立:-pauth/install 不算 TUI;npm 包装后进程 comm 可能是 node,须 cmdline 兜底;位置参数里的接力正文不当子命令。协议与现场证据见身份设计;扫描消费边界见扫描知识库 §2.2.1。
  • 已经跑着、尚未落盘的 Pi 新会话必须保留 provisional 绑定。Pi 要到第一条助手回复完成才创建 JSONL;2026-08-26 真跑还确认 /name 只更新内存显示、同样不会强制落盘。claim 会先给出预定 session id/path,这期间不得删占位卡,也不得拿同 cwd 或旧隔离目录的其它 JSONL 替它转正。旧 _claim_unique_hosted_newcomer 只用于升级窗口中的旧进程。
  • corral-* / pickup-* 历史在交互启动时由 pi_migration.py 幂等补齐:只复制 header id 与目录 ident 精确一致的主 JSONL,subagent 不匹配而跳过;活动目录延后;目标按文件名和 header id 查冲突,同 hash 视为已完成,不同内容绝不覆盖。首版 copy-through 保留源目录作为回滚备份,journal 在 ~/.cache/corral/pi-migration-v1.json。迁移后真机 pi --resume 已验证只出现主会话,不出现内部 subagent。测试夹具的目标路径必须走 _encode_cwd:macOS 上 /tmp 的真实路径是 /private/tmp,手写 --tmp-project-- 会让「目标已存在且内容不同」这条装在错误目录里,搬家函数按真实路径另写一份,冲突计数为 0——这是夹具假失败,不是搬家半成品。2026-08-30 本机 journal:copied/already/conflicts 全 0,只跳过 ProxyAgent 下一个空的 pickup-622e6410(目录 8-23 建、0 个 jsonl);看得见的 Pi 历史在各项目默认目录,搬家不会删源文件。发版门禁遇到未跟踪的搬家测试挂了,先核是不是这条夹具,不要当成「别人正在搬家所以不能发版」。
  • Pi 是默认命令托管目标:交互式裸 pi 进入 corral 托管;installremoveuninstallupdatelistconfigauth--export--list-models 必须直通真实 Pi。已有 corral 托管标识时也必须放行,防止嵌套托管递归。

扫描性能

  • 首屏延迟目标 ≤1s(已放宽为非阻断,见 AGENTS.md「验证要求」)。 当前路径:main()store.load()(→ registry.scan_all())丢进后台 daemon 线程,同时跑 _probe_osc_colours(),再 run_app() 先画出骨架(空列表 +「+ 新建会话」);MainScreen@workstore.wait_loaded() 后再 rebuild。扫描没跑完时页头不得误报「未找到任何会话」——必须等 store.loaded。直启/_dispatch_direct_launch 等仍可同步预加载后再进 UI。StartupLatencyTests 测的是 scan_all(50) 本身耗时,不是「进程启动到首帧」墙钟。
  • scan/claude.py/scan/codex.pyscan_sessions() 接受 limit,但早期实现会先对全部历史会话文件做完整的头尾 JSONL 解析,再 results[:limit] 截断——不管 limit 多小都要扫完全部历史(本机曾实测 796+1074 个文件耗时 ~5s,是 corral 启动慢的根因)。现在改为先用 os.stat 按真实文件 mtime 把候选文件排好序,凑够 limit 条有效结果就停止;新增或改写这两个扫描函数时不要退回“先建全量列表再截断”的写法。因为时间修正已经收敛成单会话 effective_session_time 判断(见「标题与排序」),提前停止不再需要按分钟桶粒度对齐。
  • Codex 侧提前停止前必须先按真实文件 mtime(os.path.getmtime)重新排序,不能直接用 _find_all_session_files 现成的按文件名(创建时间)排序去做提前停止——同一会话被续接时 mtime 会变但文件名不变,按创建时间提前停会漏掉“很久以前创建、但刚被续接”的会话。
  • runtime/registry.pyscan_all()ThreadPoolExecutor 并发跑各运行时的扫描:各运行时读的是完全独立的目录、无共享状态,线程池只是为了重叠磁盘 I/O 等待。实现了 scan_signature() 的运行时可复用上一次扫描。OpenCode 签名必须同时包含数据库/-wal mtime 和排序后的 (pid, cwd) 全量进程快照(不再按 cwd 折叠成单 pid)——只看文件时间会在进程退出但数据库不再写入时把“运行中”永久冻住。Claude/Codex/Cursor/Kimi/Pi 禁止用祖先目录 mtime(追加不冒泡),改走逐文件 stat + pid 快照;macOS 上 live_processes 必须一次合并 lsof 查 cwd,并按 pid 集合缓存,禁止逐 pid fork。
  • registry 缓存与失败回退的可变对象边界:缓存保存、命中返回和旧结果回退都必须逐条 dict(session),因为 SessionStore/liveness.annotate() 会就地注入 keepalive_name 等展示字段;直接返回缓存对象会让调用方反向污染下一轮扫描。带新签名的 scan_sessions 失败时不得用空列表或新签名覆盖最后一次成功缓存:已有缓存返回其副本,首次失败才降级为空,并在同一签名恢复后重新扫描。未实现签名的运行时仍保持单次异常隔离为空。agent_api.py_scan_runtimes 是独立扫描路径,不复用 TUI registry 缓存;改通用异常隔离语义时仍要检查两处是否需要同步。
  • cwd 判活必须按 cwd 记忆化,不能逐会话裸调 os.path.isdir 排查过一次首屏 >1.3s 的问题:scan_sessions 循环里对每条候选会话的 cwd 都单独调用 os.path.isdir 判断目录是否还在(用于过滤已删除工作目录、无法 resume 的会话),但实测本机一次扫描里几百条候选会话经常只对应十几个不同的 cwd(同一项目下反复续接);这些 cwd 常年落在 Syncthing/网络同步目录上,单次 isdir 实测 ~5-10ms,去重前光这一项就吃掉 profile 里 0.6s+ 的裸开销。修法是在 scan_sessions 内建一个按 cwd 缓存结果的 isdir 闭包(单次扫描内 cwd 存在性稳定,用完即弃),两个扫描函数都要保留这个闭包,不要退回裸调用。
  • Claude 侧完整解析前必须先用廉价预探(_peek_head_meta)拦掉自产噪音会话、Teammates/subagent 内部会话和死 cwd 会话,不能等整文件解析完才丢弃。 Teammates 模式会把每个 teammate 写成项目根下的独立 .jsonl会话开头通常含 type: "agent-name"(或 isSidechain: true);有些版本缺少这两个标记,首条用户输入会以 <teammate-message teammate_id="team-lead"> 开头,也必须用 _is_internal_claude_session() 过滤。只看开头身份,见到首条非 meta 用户消息就停。 Claude 2.1+ 会给顶层会话自己写入 type: "agent-name"(kebab-case 显示名,出现在首条真人消息之后);若扫完整头部任意一处命中,正在用的真会话会从列表消失(2026-08-15 真机:两条刚开的托管会话在 Claude 写出显示名后被滤掉)。不能teamName,也不能按任意 <teammate-message> 过滤:team lead 会话同样带 teamName,且会收到成员回报;误杀会导致用户真实会话消失。后写入的 kebab-case ai-title 不得盖掉已有可读标题。Task 工具子 agent 在 <sessionId>/subagents/ 子目录,扫描器不递归,无需额外处理。后台标题生成会调用 claude/codex,在 ~/.claude/projects/ 留下以 titles.PROMPT_MARKER 开头的噪音会话;这类会话和 cwd 已删的会话本来就会在解析后被过滤,但过滤发生在读完 300 行头部 + 64KB 尾部之后,白白解析。实测本机为凑够 30 条有效结果,_build_session_info 曾被调 347 次,其中一大半是最终会被丢弃的噪音/死 cwd 会话。_peek_head_meta 只读头部 ≤40 行拿 cwd、首条用户消息与 subagent 标记,探到确定是噪音、内部 subagent 或死 cwd 才提前 continue;探不到(如头部很长的真实会话)时不跳过,照常走完整解析兜底——改动前后结果必须字节级一致(id 顺序、fallback_titlenative_title 全部相同),新增类似优化时也要用这个标准核验。Codex 侧噪音少,只做了 cwd 记忆化,没加预探,保持简单。
  • 上述两项优化落地后,本机实测 limit=30 时 Claude 扫描从 1320ms 降到 225ms、Codex 从 585ms 降到 243ms,scan_all 并发后首屏约 0.25s;limit=50(默认值)约 0.6s,仍在 1s 硬指标内。
  • 评估过给单文件解析结果加磁盘缓存(按 路径+mtime+size 做 key)的方案,判断当前收益不足以覆盖风险,暂缓:cwd 记忆化 + 预探已经把首屏压到 1s 硬指标以内,缓存要处理和后台标题生成进程的并发写、文件被删除/截断重写后的失效判断,复杂度换来的收益不划算。若历史继续增长导致启动明显变慢(>1s)或用户明确要求,再按 titles.py 已有的原子写 + 内容指纹失效模式加缓存。
  • 本机历史数据量增长后,scan/claude.py 的头部解析(_read_head,最多 300 行)重新成为首屏耗时大头:实测本机真实会话文件里,前 300 行经常混入几百 KB 甚至上 MB 的 assistant/工具调用大段嵌入内容(大文件读取、工具结果),逐行 json.loads() 解析这些巨大行很贵;同时后台标题生成积累的自产噪音会话(PROMPT_MARKER 前缀)在候选文件中占比可能相当高(本机实测一次凑够 50 条有效结果需要探测 130+ 候选,其中过半是噪音),叠加多个真实 codex/claude 进程并发争抢磁盘和 CPU 时,scan_all 有概率短暂超过 1s。曾尝试给 _read_head 加字节预算提前停止(仿 _read_tailmax_bytes),用本机全部 1263 条真实会话验证后发现 196 条(约 15.6%)fallback_title 结果改变,且抽查显示新结果通常比原结果质量更差(挑到的是对话里更靠后、更简短的跟帖消息,而不是最初的完整需求描述)——已回退,不要重新引入这个方向。 若要继续优化头部解析开销,应该在不改变"必须完整扫描到能覆盖 ai-title/last-prompt 出现位置"这个约束的前提下想办法(如按类型子串先廉价过滤要不要整行 json.loads,同时确保时间戳提取仍走完整解析,不能用无法区分嵌套字段的正文子串匹配去猜时间戳)。
  • _choose_claude_fallback_title 同分候选(如两条 last_prompt)平手时用 max() 取先出现的那条:这是有意保留的行为,不是 bug。头部/尾部按时间顺序把候选依次加入 title_candidates,同源同分时"先出现"往往对应对话里更早、更完整的原始诉求,而"最后出现"经常只是简短的追问或跟帖(已用本机真实数据核验:反转成"取最后一条"会让约 15.6% 的会话标题变得更模糊、更不能代表这次会话在做什么)。不要假设"更晚出现的候选更能代表用户意图"去改这里。

界面

唯一界面是 Textual 左右分栏(ui/main_screen.py):左栏会话列表 + 右栏对话预览/内嵌终端。旧 curses 全宽列表、Space 全屏预览页均已删除,禁止再加回第二套界面。改界面行为以 ui/ 源码与 test_ui.py 为准。

  • MainScreen 按领域拆分为 mixin 方法容器(2026-08-05,v0.24.53):主控已从 2200 行拆到约 1450 行,五个领域的方法迁到 ui/controllers/ 下的 mixin(layout / attention / host / hud / update),MainScreen 多重继承它们。约定:① 状态仍挂在 MainScreen 实例上__init__ 里的 self._xxx 一律不动),mixin 只是方法容器——不要为了"面向对象"把状态搬进 mixin 类;② 每个领域的模块级常量跟随方法搬(如 _ATTENTION_READY_POLLattention_reader.py),谁引用谁定义;③ MainScreen.<method> 经 MRO 仍全部可解析,文档锚点不失效;④ 测试用 mock.patch 注入的常量目标路径必须随搬迁同步改为控制器模块路径,否则 patch 打在旧命名空间上不生效(test_attention_ui.py 的 poll 间隔即打在 controllers.attention_reader._ATTENTION_READY_POLL);⑤ Textual 的 @work 装饰器与 action_* / on_* 在 mixin 里照常生效(descriptor 沿 MRO 解析),新拆领域时可以直接把框架回调一起搬过去。

  • TUI 多语言(i18n.py:界面默认英文;系统 locale 主语言为 zh*LANG/LC_ALL/LC_MESSAGES/LANGUAGE)时自动中文。CORRAL_LANG=en|zh 可强制覆盖。机器接口(corral list 等 JSON)不走翻译。新增用户可见文案必须进 _MESSAGES 并同时写 en/zh;测试固定 i18n.set_lang("en") 再断言,中文覆盖用 test_i18n.py。Textual 的 BINDINGS 在类创建时会冻结:MainScreen__init__ 里用 dataclasses.replace 只改 description,禁止整表替换 _bindings(会丢掉 ListView 继承的 up/down/enter,表现为方向键失灵)。

  • 判定 Textual Pilot 用例是真回归还是既有偶发(2026-07-30)AGENTS.md 说「失败用例单独重跑一遍确认」,但单跑一次不够——test_focusing_split_pane_highlights_matching_sidebar_session 会在单跑时也失败(_wait_until 等满 5s),连续跑 5~8 次才出现 1 次,改动前后的偶发率一样。正确判定路径:① 同一用例在仓库目录内重复采样 5 次以上统计失败率;② 用 git archive HEAD | tar -x -C <临时目录> 拉出改动前的源码,并显式 PYTHONPATH=<临时目录>/src 再跑同样次数对比。注意第 ② 步不加 PYTHONPATH 会因 editable 安装的 .pth 仍指回工作区 cli/src,实际测的还是改动后的代码,得出「改动前能过」的错误结论。两边失败率相当即为既有偶发,可照常发版。该偶发已于 2026-07-31 定位并根治(实测 40 次全过,修复前 8/40 失败),根因与修法见下面「焦点竞态」一节;本条保留的是那套判定方法本身,仍然适用于将来任何新的 Pilot 偶发。补一条量化教训:20 次一组的采样会被噪声骗到(同一改动量到「前 3 挂 / 后 6 挂」像是翻倍回归,换 40 次一组则两边都是 8/40),判定回归至少用 40 次一组。

  • CI 上的判定已经自动化,不要再让偶发变成失败邮件(2026-07-31):上面那条「失败用例单独重跑」以前只写在文档里靠人执行,GitHub Actions 仍旧一失败就发邮件。现在 CI 走 scripts/ci-test.py 而不是裸 python -m unittest discover:首轮失败的用例会被自动单独重跑一次,两次都失败才判真回归(真回归是确定性的,重跑照样挂,不会被这层重试掩盖)。该脚本同时用 faulthandler.dump_traceback_later 兜挂死,见下方「CI 工作流」节。

  • 点击崩溃(2026-07 真机实报,已修复):Textual 默认给所有 Widget 开启内置的鼠标拖拽文本选择(ALLOW_SELECT = True)。SessionCard/NewSessionCard 这类会被后台重扫线程动态增删的列表项 Widget 被点击时触发该逻辑,选择过程中控件被移除会导致 container 解析为 None,访问 .region 直接 AttributeError 崩溃整个应用。修法:CorralApp/SessionCard/NewSessionCard/弹窗菜单项 _ChoiceItemALLOW_SELECT = FalseEmbedPane 保留默认值用于划词选中+复制(见「内嵌面板」节)。回归:test_ui.pytest_clicking_session_card_selects_and_launches_without_crashing

  • 点击崩溃第二波:启动首屏点鼠标闪退(2026-08-05,用户实报;2026-08-03 已静默发生过一次):症状与上一条同为 AttributeError: 'NoneType' object has no attribute 'region',但ALLOW_SELECT 治不了——上一条只堵住了「被点的那个控件自己允许选择」,这次是合成器命中表的时序问题:全量重建列表 / 换格的那一两帧里,get_widget_and_offset_at() 仍会给出已被移出 DOMparent is None)的控件,Textual 8.2.8 的鼠标按下与拖拽两个分支都直接取 parent.region。触发条件是启动首屏重建期间点一下鼠标(events.log 里两次都是 list_rebuild full 紧接一条 error),命中概率低但完全随机,用户看到的就是「刚启动就闪退」。上游同类问题 Textualize/textual#5629 未修,8.2.8 已是最新版,只能自己兜。修法:MainScreen.get_widget_and_offset_at() 覆写,命中控件 parent is None 时当作没命中返回 (None, None),一次盖住按下与拖拽两条路径。不要退回「在 _handle_exception 里吞掉 AttributeError」那种兜法——那会把真实的空引用缺陷一起藏起来。回归:test_mouse_down_on_detached_widget_does_not_crash

  • 分屏标题挂载竞态(2026-07-22,用户实报闪退):分屏外框进入父节点后,标题栏自身的 compose() 仍可能未完成;这时后台重扫或侧边栏高亮变化会走同身份原地更新,若再用 query_one(".title") 查尚未挂载的后代,会抛 NoMatches 并退出整个 TUI。修法:需要在挂载前更新的子控件应在父控件构造时创建并保存直接引用,compose() 只产出该实例,更新方法直接改引用;不要把“父控件已能从父级 children 找到”误当作“它的后代已完成挂载”。回归:test_pane_header_title_update_before_compose

  • 窗口缩放花屏(2026-07-20,iTerm2 真机实报):拖动终端窗口时备用屏幕会被终端自行 reflow,Textual 默认只差分重绘布局变化区域,两边对不上就整屏残影/错位。修法分两层且必须防抖:① CorralApp._check_resize 不立刻全量刷,只重置约 120–200ms 计时器,尺寸停稳后 _force_full_repaint 把整屏标脏走 compositor 全量路径一次;② 右栏 EmbedPanetmux resize-window + 唤醒抓帧同样防抖,拖动期 render_line 只按当前宽度裁补旧缓存行,不在主线程狂刷 tmux。禁止改成「每次 Resize 都整屏全量重绘」——拖动会卡顿闪烁。回归:test_resize_full_repaint_is_debouncedEmbedPaneResizeTests

  • 窗口缩放后 Cursor「疯狂滚动」数秒(2026-07-22,iTerm2 真机实报):外层停稳后一次 resize-window 会让 Cursor/Claude 等按新尺寸整屏重排,重排过程会把对话从早到晚刷过一遍;corral 若按 %output 全速镜像,观感就是右栏狂滚再停在最新。修法:已有 live _grid 时,resize 后开启抓帧冻结(_begin_resize_capture_hold),最短约 0.35s、最长约 3s,连续两帧画面相同才放行并一次跳到最新;中间态不刷 UI。回归:test_resize_with_live_grid_starts_capture_holdtest_resize_hold_deadline_forces_release

  • 窗口缩放崩溃 IndexError(2026-07-20,suzhou SSH 真机实报):高度骤变时 Textual ChopsUpdate 偶发 chops[y] 越界(spans 仍引用旧高度)。默认 _handle_exception 会直接退出整个 TUI。CorralApp_check_resize 里给恢复额度,命中 IndexError 时先整屏重绘自愈,额度耗尽才走致命路径。回归:test_compositor_index_error_recovers_instead_of_exiting

  • 长跑双分屏 LRUCache KeyError 闪退(2026-08-06,用户实报,v0.24.55)corral 跑数小时后,右栏两格 PaneCell 布局时 Widget._get_box_model 写入 _box_model_cache,Textual LRUCache.set 驱逐最老项时 del self._cache[last[2]] 因链表与 dict 不同步抛 KeyError,默认 _handle_exception 直接退出。上游 8.2.8 仍是裸 del。修法:ui/textual_patches.py 在导入 CorralApp 时给 LRUCache.set 打安全驱逐补丁(KeyErrorclear() 后重试一次);不要_handle_exception 里吞任意 KeyError。回归:test_lru_cache_set_survives_desynced_eviction

  • 强停打出 Python 堆栈、退出后点击变成 ^[[<0;21;2M(2026-08-29,Warp 真机):Ctrl+R 是 Warp / zsh 的命令搜索,corral 不绑定它(绑了会抢走托管助手的 readline 反搜)。Warp 把 Ctrl+C 打成 SIGINT,asyncio.run 收尾等默认线程池最长 300 秒,第二次 Ctrl+C 在 shutdown_default_executor 掀堆栈;Textual 关鼠标跟踪走写线程,此时已经停了,跟踪留给外壳。修法:run_app()KeyboardInterruptrestore_terminal() 直接 os.write 关闭序列。不要把 Ctrl+R 改成全文搜索。回归:InterruptTerminalRestoreTests

  • 分屏托管窗宽度错位、画面只占约 1/5(2026-08-14):三处独立都能单独造成错宽。① host_pane_size(row.width)//count 估算、不扣格间距、也不按 Textual 1fr 取整,新建后马上又 resize,正卡在 agent 启动窗口期;漏掉 SIGWINCH 后窗口再无变化,低于 40 列还会被 MIN_HOST_WIDTH 钉死。② embed.resize 经控制通道 fire-and-forget,EmbedPane._host_size 记的是「我请求过的尺寸」,请求没落地时后续 Resize 会被当成已应用而跳过。③ _projected_embed_sizes 把余数堆给末格,Textual HorizontalLayout 却是 Fraction 累加后 floor(next)-floor(x);四格且行宽为 101/105/…/189 时实际是 [n, n+1, n, n+1]、预测是 [n, n, n+1, n+1],中间格 _capture_size_override 因不相等永不清除,关格变宽后仍按旧窄宽解析、右侧补白。修法:pane_state 同一次回读真实宽高,抓帧优先用 tmux 列数;预测改成与 Textual 同一套算法,host_pane_size 复用「加上这一格之后」的末格;Resize / target_size=None / clear 无条件清 override;抓帧线程对账漂移,约 2s 退避、同一目标最多 3 次,并记 host_size_drift。回归:test_projected_embed_sizes_match_textual_floor_accumulatetest_focus_session_without_target_size_clears_stale_overridetest_capture_size_prefers_tmux_real_size_over_widgettest_host_size_drift_retries_resize_with_backoff消歧(2026-08-29 / 2026-09-03):两格分屏每格内容只占一半(像整屏 1/4)、或单格/分屏里 Claude 只占约 1/3 右侧大块空白——若另一扇 corral 开着活跃会话 / 更多格,或控制通道把窗打回 80 列,这是共享画面被较窄观看方压窄,不是本条创建期窄宽。2026-09-03 起较窄方不许压窄,见 内嵌实时终端知识库 §6。

  • 多开窗口把两格分屏压成约 1/4 / 分屏 Claude 只占约 1/3(2026-08-29 机主实报,2026-09-03 再报仍在):A 窗口两格或单格看托管会话,B 窗口打开活跃会话看板、或控制通道 window-size latest 把窗打回默认 80 列,A 的格子仍是原来的宽、内容却只占约 1/3~1/4,右侧空白。不是单窗口把宽度除了两次。旧行为「后布局者赢 + 对齐成功后不再拉回」会把较宽窗口钉在窄宽上。现改为各窗口登记观看尺寸、有效尺寸取最宽观看方,较窄方 crop;保活配置改为 window-size manual(已运行的 server 在 resize/host_session 时补 set-option)。不要为消掉压窄去改 host_pane_size / 预测宽度。回归:test_desired_host_size_prefers_widest_live_viewertest_host_size_heal_grows_back_when_shrunktest_tmux_config_uses_manual_window_size

  • 往上滚出现「只剩几列」的历史(2026-07-20,suzhou 真机实报):右栏短暂缩到极窄(布局未稳、终端被拖得很小)时若仍 resize-window,Cursor/Claude 会按当前列数硬换行写入 scrollback;之后右栏恢复正常宽度,直播区看起来正常,但往上滚仍是窄条。下限:embed.MIN_HOST_WIDTH/HEIGHT(40×10);创建用 normalize_host_size 抬到下限,后续缩放用 should_resize_host 过滤,过窄直接跳过、保留上一次可用尺寸。embed.resize 自身再兜底一次。已烧进历史的窄折行无法自动还原,只能靠新输出覆盖。回归:test_normalize_and_guard_host_sizetest_tmux_resize_skips_when_pane_too_narrow

  • 侧边栏被几天前的 Codex 管家会话刷屏(2026-07-22):OpenConductor 自动任务 cwd 在 /tmp/oc-manager-codex/...;目录删掉后会话被「cwd 不存在」滤掉,目录再建时整批复活,_merge_scanned 把它们全当 fresh 按 mtime prepend。修法:扫描丢弃 oc-manager-* 路径段;fresh 仅近 2 天 prepend、更旧追加末尾。回归:test_scan_filters_ephemeral_oc_manager_cwdtest_all_sessions_append_resurrected_old_sessions_on_refresh

  • 内嵌面板滚动卡顿(2026-07 真机实报,已修复):滚轮/resize 不得在 Textual 主线程同步 embed.capture()/embed.pane_state()。抓帧走后台线程(EmbedPane._capture_loop),mouse_any/mouse_sgr/history_size 等为后台写、主线程只读缓存;事件处理只更新 history_offset 并用 _poke 唤醒补抓。

  • 内嵌面板滚动卡顿第二波(2026-07-19,v0.17.4):Claude Code 自 v2.1.88 起托管 pane 常开 SGR 鼠标捕获,滚轮主路径变为转发 press-only 序列;转发必须经 embed.send_mouse_sequence 后台队列(限速、积压丢旧),主线程零 fork;pane_state 查询降频到 5Hz。回归:test_embed.py / test_ui.pyEmbedPaneWheelTests

  • 已结束会话预览滚轮方向(2026-07-19,v0.18.2)detail_offset(0=顶部)与 history_offset(0=直播底)符号相反;详情态 _wheel 必须对 local_delta 取反再 scroll_detail

  • 静态预览默认钉底(2026-07-21):选中已结束/未托管会话时 _detail_stick_bottom=True,窗口与 detail_offset 贴最新消息;异步暖加载正文变长后仍钉底。用户上滚或 Home 后取消钉底,之后 invalidate_detail(列表刷新)只 clamp 当前位置、不强制跳回底部;End 或下滚到尾重新钉底。回归:test_right_pane_detail_scrolls_with_page_and_endtest_detail_async_load_pins_to_bottom

  • 托管首帧回退顶裁闪回会话开头(2026-07-22,用户实报):Cursor 等持续输出时,分屏 remount / 重扫会让 focus_session(fallback) 清空 _grid;旧逻辑用 to_strips(..., height=pane_h) 从对话顶部裁一屏,观感是「突然跳回最早消息再滚回最新」。修法:① 有 fallback 时钉底,托管等待首帧走 _uses_detail_window() 整篇+窗口;② show_hosted_group(session_key, keepalive) 有序身份不变时就地更新、禁止整排 remount;③ store.hosted 仍登记时活跃判定不依赖单次 is_alive。回归:test_hosted_fallback_pins_long_conversation_to_bottomtest_same_hosted_identity_skips_remount_keeps_live_gridtest_hosted_registration_keeps_session_active_without_is_alive

  • 新建 Codex 右栏突然变空(2026-07-22,用户实报):空白新建先登记 8 位临时会话键,Codex 写出真实历史后扫描器会用正式 UUID 替换占位卡。旧逻辑只按同一托管名迁移分屏记忆和右栏格,没有迁移侧边栏当前选中键;列表重建找不到旧键便回到顶部「+ 新建会话」,随后选择跟随把仍在运行的右栏覆盖成新建提示。修法:分屏键对齐时返回旧键→新键映射,列表重建在读取旧 DOM 选中键后同步映射并强制选中正式卡;所有主屏列表重建还必须串行,避免后台重扫与交互刷新并发清空、挂载同一批条目。回归:test_reconcile_split_keys_after_provisional_becomes_realtest_screen_serializes_concurrent_list_rebuilds

  • 搜索框连续退格打崩 TUI(2026-07-26,真机 DuplicateIds:症状是 corral 整个界面闪退,~/.cache/corral/embed-error.log 里留下 Tried to insert a widget with ID '__new_session__'events.log 崩溃前是一串 mode=fulllist_rebuildcard_count 50→57→71,单次已达 2s)。根因:全量重建的 clear()/extend() 都会 await 让出,而调用方跨两条 Textual 消息泵——后台重扫经 app.call_from_thread(_rebuild_list) 在 App 泵,搜索框输入经 on_input_changed 直接调 SessionListView.rebuild() 在 Screen 泵——MainScreen._rebuild_lock 只挡同泵重入,两泵交错时前一次的 extend 把新建项挂到后一次已填好的列表上。修法:闸门下沉到 SessionListView.rebuild() 自己的 _rebuild_lock,并用请求序号做合并(排队期间有更新请求且本次无 select_key 就让位,连续输入只重建最后一个筛选态)。任何新增的列表刷新入口都必须走 rebuild(),禁止另写路径直接改 ListView 子项结构。 回归:test_list_rebuild_serialized_across_message_pumps(临时去锁验证过必崩,测试确实盯住了这条竞态)。

  • 会话列表刷新开销优化(2026-07-19):会话键序列不变时 rebuild() 走原地更新;变了才 clear()+extend()SessionCard 的标题由外部注入,禁止每卡 snapshot()。标题生成中不在侧边栏画 spinner。完整对话只由右栏 EmbedPane.show_detail 承担,禁止再加全屏预览页。回归:test_ui.py 相关 rebuild 用例。

  • Textual 后台 worker 必须可取消:用 get_current_worker().is_cancelled / cancelled_event.wait(interval);worker 内不得直接读写 Widget/DOM,结果经 call_from_thread 回写。托管启动同样走单飞 worker。

  • 侧边栏末行间隔(硬约定):搜索框、新建项的最后一行是间隔空行,画在控件自身高度内并算进命中区;禁止用 margin/兄弟空隙/ListItem padding。会话卡固定三行正文(首行最左关注圆点 + 空格分隔的「项目 标题」/ 运行时靠右 / 时间靠右),不再另加末行空行;单圆点优先级为黄 > 绿 > 红,详情头有文字状态。基准:搜索框高 2、新建项高 2、会话卡高 3。

  • 筛选状态只认 nav 一份:顶部搜索框写 nav.project_query;测试必须断言渲染结果。

  • 卡片列宽按终端显示宽度计算(corral.textutil.text_width / fit_cell,包顶层兼容名 corral._text_width / _fit_cell),不要用字符数 ljust

  • 主界面同时消费进程活性与会话关注状态,但两者不同:live 只表示进程在不在;关注圆点表示等待回答/执行中/新结果未读;titles.status_tagagent_api 英文枚举又是已发布的第三套语义。三者不要混用或互相覆盖。

  • 判活只做「进程在/不在」两档;Claude / Codex / OpenCode 各自判活细节见对应扫描节。

  • 聊天预览按需读取,只展示真实用户消息与最终答复;消息可选带 timestamp,有则由 _preview_lines 追加时间后缀。

  • 会话缓存按 mtime 失效get_conversation 命中时比对文件 mtime,变了才重读。

统一会话时间线与右栏跟随

  • SessionStore.all_sessions() 合并全部运行时后按 _order 稳定顺序:首次按 mtime 倒序;之后已有项位置固定。新出现且 mtime 在约 2 天内的插最前;更旧的「复活」会话追加末尾(避免 /tmp/oc-manager-* 目录重建时几天前的会话整批顶栏)。扫描侧丢弃路径含 oc-manager-* 段的临时 cwd。
  • 列表虚拟索引 0 是顶部固定「+ 新建会话」;默认选中最近会话。该项回车走 new_session_flow;底栏不再提供 n 快捷新建(改走侧边栏项或右栏顶栏加格)。
  • 踩坑:空白新建闪退——托管成功回调必须区分 LaunchRequest / NewSessionRequest,空白新建禁止读 .session。回归:test_new_session_request_hosts_without_reading_session
  • 会话卡固定三行正文:首行「圆点 项目 标题」(空格分隔、无冒号,无圆点时不留占位空格、标题顶到最左)/ 运行时靠右 / 时间靠右;圆点优先级为等待回答黄 > 执行中绿 > 未读新结果红 > 无,标题不再因运行中整行变绿;首行整体 bold 但项目名叠 dim 比标题淡一档(标题不 dim);标题生成中不画 spinner。
  • 项目搜索#project-search + nav.project_query/ 聚焦搜索;Down/Enter 回列表;Esc 先清空再回列表。
  • 右栏随选择变化:托管显示现场;未托管/已结束显示完整对话预览(选中即加载,默认钉在最新)。面板聚焦时列表→右栏跟随暂停;右栏→列表仍要同步高亮(_on_pane_focused)。长对话用 Home/End/PgUp/PgDn 或滚轮(detail_offset / _detail_stick_bottom)。
  • 点击会话卡等价 Enter(真的会拉起 / 接管会话,并把输入交给右栏那一格);再点当前持有输入的那张卡则把焦点撤回侧边栏,与 Ctrl-\ 等价,点开 / 收回对称。

侧边栏记忆的多窗口一致性(split_layout.py / ui_prefs.py)

会话组、置顶、折叠、上次焦点和侧栏显隐存在 ~/.cache/corral/sidebar-layout.sqlite3同一台机器上的所有 corral 窗口共享这一份

  • 写入模型:每次改动都是「BEGIN IMMEDIATE → 重读最新快照 → 重放这一次改动 → 整表写回 + revision 自增」。 数据量只有几个组和几条置顶,整表写回比按行 diff 简单,正确性由事务保证。界面手上的 SplitLayoutStore 是只读快照,写入一律经 MainScreen._apply_layout_change()SessionListView 只表达意图(传一个改动函数),不碰存储。
  • 为什么不能退回 JSON:旧实现是每个窗口启动读一次、之后整份覆盖写。同时开两个窗口时, 后动手的窗口会把先动手窗口的改动整份抹掉——丢的不是一条,而是全部置顶 + 全部分组; 两个窗口之间也永远看不到对方。这是 2026-08-04 机主实报后重做的根因。
  • 跨窗口同步:每秒读一次 revision(单行 SELECT)。版本变了才读整份快照,且只有 sidebar_fingerprint()(不含焦点字段)真变了才重建列表——全量重建是秒级重活。
  • 连接是常驻的(v0.24.132 起)SidebarLayoutDB 缓存一条 check_same_thread=False 连接, 读写都持实例锁串行;出错时丢弃缓存下次重开(自愈)。禁止改回「每次 connect+PRAGMA+建表+ 迁移探测+close」——单次写实测 8~18ms 全压在界面线程,且 read_revision 是每秒轮询路径。 多窗口互斥仍由 BEGIN IMMEDIATE 保证,与连接生命周期无关。
  • 焦点写入必须与组合写入分开_persist_split_focus()set_focus)只更新焦点, _persist_split_composition()set_group)才断言组合。混用会让「另一窗口移出成员」和 「本窗口切焦点」互相拉锯,组反复消失重建、组名重新随机。
  • p 的翻转以库里最新状态为准,不看本地快照。极端情况下(对方刚改完、本窗口 1 秒同步还没到) 按 p 可能翻到与屏幕显示相反的一侧;窗口期约 1 秒,是有意接受的取舍。
  • 旧文件迁移只读不动:首次开库导入 split-layout.json / ui-prefs.json 后置 imported_legacy不改名、不删除——升级期间机器上很可能还开着跑旧代码的窗口,仍在按秒往那两个文件里写。
  • 降级:库打不开(只读盘、损坏)时回落进程内内存状态,只警告一次,界面照常工作。
  • 动这块的测试或临时脚本必须设 CORRAL_CACHE_DIR,否则旧文件迁移会去真实 ~/.cache/corral 找文件。真踩过:一个只想验证并发写的脚本没设,把本机 ui-prefs.json 迁走了。

会话级快捷键

  • a/q/x 等会话动作集中在 MainScreenui/modals.py;不要再拆第二套「预览页专用」按键分发。侧边栏选中/托管不抢右栏焦点;滚轮按命中区处理。
  • 新建:侧边栏「+」走 new_session_flowNewSessionModal一个弹窗,左栏项目 / 右栏运行时,左宽右窄 2fr:1fr;打开即聚焦左栏筛选框,立刻可打字,禁止默认钉到项目列表);顶栏点助手走 _on_runtime_pick(当前项目加格)。cwd 仍由 _new_session_cwd / area.current_project 解析;选中项目目录已不存在时 beep 并放弃,不静默返回。
  • a:无具体会话时只 beep。
  • 接力运行时选择走 RuntimePickerModal;新建会话走 NewSessionModal

运行时边界

  • 界面和接力编排禁止新增 if source == "claude" 这类运行时分支。
  • 运行时私有扫描格式、恢复参数和新会话参数必须留在对应适配器中。
  • 公共流程只依赖注册表和统一接力模型。

接手提示词的对话摘录(Handoff.conversation_digest

  • 摘录在 BaseRuntime.export_handoff 统一构建(调用 self.load_conversation,运行时无关), 不在各适配器里各写一份;render_prompt 只负责渲染。为什么要有摘录:标题最长十几个字, 作为任务说明极度有损;原始 JSONL 尾部常是工具结果/系统注入事件等噪音,冷启动的目标 agent 首次解析容易定位错重点。load_conversation 已踩平真实格式坑(过滤系统事件、None 兜底), 用它提取的摘录给目标 agent 一个可靠锚点。
  • 原始历史文件仍是权威来源:摘录在提示词里明确标注"截断版、以历史文件为准",阅读指令 改为"以摘录为线索核对补全",不能让目标 agent 只信摘录不读文件。
  • 摘录构建失败必须静默降级load_conversation 异常/为空时回退扫描层的 first_user_msg/last_user_msg/last_agent_msg,再空则 digest 留空串、提示词退回无摘录 形态——任何情况下不允许因摘录失败阻断接力。
  • 摘录里的角色标签是"用户"/"助手",不能用"你"——摘录是给接手的大模型看的,"你"会被 它误解为指自己(用户明确纠正过)。消息压平成单行再截断(_clip),多行原文会破坏逐行结构。
  • corral contextsuggested_prompt 与 TUI a 接力共用同一个 render_prompt,改摘录格式时 两边同时生效,同步检查 docs/SKILL.md 的描述。
  • 接力提示词刻意不注入源会话的列表状态标签(status_tag,如 ✅已完成/待回复/已中断: 接力的目的就是接着往下干,一旦开头告诉接手 agent「会话状态:✅已完成」,它极可能直接回 「当前没有待办、等新指令」,把接力废掉。是否还有未完成任务由接手 agent 自己读历史 + 看工作区 实际状态判断,render_prompt 末尾已有对应指令。因此 Handoff 不再带 status_note 字段, export_handoff 也不再从 status_tag 取值——不要为了「让接手方知道原状态」把这行加回来。

会话保活与存活判定(keepalive.py / liveness.py)

  • 两层不要混改。 keepalive.py 只做启动包装:把启动计划包进独立 tmux socket、空闲回收、tmux 配置内联落盘。liveness.py 才做「这个托管窗口还在不在、扫描结果该贴哪个窗口名」:is_alive / note_alive / forget_alive / annotate。改「会话还在不在」或「两格画面一模一样」去 liveness.py;改「怎么把新进程包进 tmux / 多久杀掉空闲窗口」才去 keepalive.py。旧名兼容(pickup- / sc- socket、环境变量)在 legacy_names.py,两层都经过它,不要把旧名散回业务代码。
  • 定位(保活):运行时无关的启动包装层,地位类似 titles.py——不属于任何 runtime/ 适配器,registry.py 只管生成 LaunchPlankeepalive.py 负责在执行前后包一层 tmux。新增运行时不需要碰这个模块。
  • 专用 socket 隔离:全部操作走 tmux -L corral-keepalive(独立 server),配套专属配置(-f 显式指定),完全不读、不写用户自己的 ~/.tmux.conf 或默认 socket。目的是让保活会话“无感”(隐藏状态栏、真彩色、window-size manual),同时绝不影响用户手动开的 tmux 会话。不要改回 window-size latest:控制通道走管道时常报 80x24,会把分屏里的 Claude 打成约 1/3 宽、右侧空白。
  • tmux 配置内容内联在 keepalive.py_TMUX_CONFIG 字符串常量里,不是仓库里一个独立的 .conf 文件——这是踩过坑之后改的:独立配置曾未进入安装产物,源码目录能跑、真实安装后却找不到。改成 _ensure_config_file() 在每次 wrap_plan 时把内联字符串落盘到 ~/.cache/corral/keepalive.tmux.conf(内容变了才重写,同 titles.py 缓存目录)。改配置内容只改 _TMUX_CONFIG 常量,不要再新建独立文件;改完实际安装 wheel 到临时目录,确认没有引入新的包外文件依赖。
  • 匹配托管窗口优先走 pid 祖先链,无 pid 时再按托管名唯一命中(liveness.annotatewrap_plan 生成的 tmux 会话名只在创建时用某个 runtime_id + ident 拼一次,之后原生恢复(如 claude --resume)可能在内部 fork/重新注册进程,导致 pane 里的顶层 pid(#{pane_pid})不一定等于运行时自己事后记录的“活跃 pid”(如 ~/.claude/sessions/{pid}.json 里的 pid)。有 pid 时 liveness.annotate() 不比较 pid 是否相等,而是一次 ps -eo pid,ppid 建出整机父子关系表,对每个候选活跃 pid 向上追祖先链,只要能追到某个 tmux pane 顶层 pid 就算命中——对是否发生过 fork 免疫。ps 而非 /proc:项目要求同时支持 macOS/Linux(见 README.md Requirements),/proc 在 macOS 上不存在,ps -eo pid,ppid 两边通用。扫描没标出 pid 时仍要 tmux list-sessions:Pi 的 jsonl 写完即关、claim 过期后 _apply_live_flags 经常拿不到 pid;旧逻辑这里直接 return,侧栏就把还在跑的托管会话画成 Enter restart,回车却走 new-session -A 接回原进程。名字兜底必须同时满足:托管前缀可解析、session.source 等于 runtime、ident 唯一命中(口径与 store._session_matches_keepalive_ident 相同);命中数 ≠ 1 则两边都不贴,禁止把 8 位 ident 猜到多条 uuid 历史上。缺 source 的夹具/历史不得靠名字贴。一个 pane 只能挂一条会话:祖先链会让父进程 pid 和子进程 pid 都命中同一份画面;若扫描把它们绑到两张卡上,两格分屏就会一模一样。已占用的 pane 名不得再分配,store 还要再去一次重。回归:test_one_pane_is_not_assigned_to_two_sessionstest_sessions_without_pid_match_unique_managed_nametest_ambiguous_name_match_assigns_neither
  • annotate()liveness.py,调用点分散在三处,故意不做成单一收敛点store.SessionStore.load()(TUI 列表)、agent_apicmd_list/cmd_search(直接 runtime.scan_sessions 拼列表)、resolve_refshow/context/plan continue 共用的会话定位)。三处各自扫描各自的会话集合,注册表层的 scan_all() 只被 TUI 用到,agent_api.py 走的是另一条按 runtime 单独扫描的路径,没有单一 choke point;annotate() 本身只读(一次 tmux list-sessions,有 pid 时再加一次 ps),开销可忽略,所以选择在每个"即将构建 session payload/渲染列表"的地方各调一次,而不是硬凑一个共享入口增加耦合。
  • is_alive 的缓存只能加速「确认还活着」,不能替代宣告死亡。 抓帧、状态查询、开通道成功会 note_alive;切换会话时的活跃判定可以带 max_age 复用这笔证据,避免主线程反复 fork has-session。判定「会话是否已结束」一律不要传 max_age。确认已死后必须 forget_alive,否则缓存会把死会话续命。
  • annotate() 只要会话列表非空就会打 tmux list-sessionsps 祖先链仍只在有 pid 时才跑。 旧逻辑「没有任何 pid 就整段 return」会漏掉 Pi 这种经常扫不出 pid、但 pane 还在的托管会话(侧栏 Enter restart、回车却 attach 回原进程)。空列表才短路。不要为了省一次 list-sessions 把无 pid 路径加回去。
  • _launch 里先无条件尝试 attach_plankeepalive_on 开关只管要不要包装新启动的进程,不管是否要接回已有的:如果某个历史会话已经被标注了 keepalive_name(意味着它当前正跑在某个 tmux pane 里),即使这次调用带了 --no-keepalive,也必须走 attach-session 接回去,不能假装没看见、重新拉起一个 claude --resume 去抢同一份会话文件——那会导致两个进程同时写同一个 JSONL,状态错乱。--no-keepalive/CORRAL_KEEPALIVE=0(旧名 SC_KEEPALIVE=0 仍生效)只影响"这次新启动的进程要不要被包进保活层",对"识别到的已有保活会话该不该接回"没有否决权。改这段逻辑前想清楚这个区分,不要把两件事合并成一个开关。
  • 回收(reap_idle:按 tmux 自己维护的 #{session_activity}(该会话最后一次有任何活动的时间戳)判断空闲时长,超过 CORRAL_KEEPALIVE_IDLE_HOURS(旧名 SC_KEEPALIVE_IDLE_HOURS 仍生效;默认 2【裁定·2026-08-30,原 6】,0 禁用)就 kill-session。不常驻额外的守护进程/定时器——main() 在进 TUI 前顺带跑一次,随 corral 的启动节奏自然触发,足够覆盖"长期没人用 corral 就不会占着内存"的诉求;会话历史本身在磁盘上,回收只是关掉后台进程,不丢数据。不要把默认改回 6 小时——本机并行十来张 Cursor 卡时,6 小时会让活动监视器里堆满已说完但仍活着的命令行助手。
  • 托管进程软上限(reap_pressure,裁定·2026-09-05):保活 socket 里托管会话总数默认软上限 12CORRAL_KEEPALIVE_MAX_SESSIONS,旧名 SC_KEEPALIVE_MAX_SESSIONS0 关闭压力回收)。超过上限时才动手:只关「不是进行中」且 tmux session_activity 已闲置超过 10 分钟CORRAL_KEEPALIVE_PRESSURE_IDLE_MINUTES / SC_KEEPALIVE_PRESSURE_IDLE_MINUTES)的会话,按空闲最久优先,关到 ≤12 或没有合格候选为止。软上限:若超限但剩余都是进行中或闲置不足 10 分钟,允许暂时超过 12,禁止为此拦截新建/直启。「进行中」= 关注状态 phase=working(侧栏绿点执行中)——长任务可能长时间无终端输出但仍在跑,不得只靠 session_activity 判断;waiting(黄点等回答)与就绪/未读都可以被压力回收。与 reap_idle(按时长无条件关)互补,不互相替代;调用点:reap()(进 TUI / 直启进 TUI,先空闲再压力)以及 embed.host_session 创建新托管前(界面开一整天不重启时靠这条压)。全屏 wrap_plan 不内嵌调用,避免单测/透传路径误触本机真实保活。同类参考:VS Code agent host 的 soft MRU residency(闲置且无活跃 turn 才释放)。禁止的误修:把软上限改成硬拦新建;把「进行中」收窄成「最近有 tmux 活动」从而杀掉长推理;把默认压力闲置改成跟 2 小时空闲回收同一个旋钮。
  • 排查「Cursor 进程过多 / 活动监视器一堆 agent」(2026-08-30 本机;2026-09-05 起叠软上限):图形版 Cursor.app 可以完全没开。每张在保活里的 Cursor 卡对应一只顶层 agent/cursor-agent(家长是 tmux -L corral-keepalive),外加它拉起的少量 node / 记忆插件。侧栏「就绪」≠ 进程已退出——只表示这一轮说完了,进程仍为下一次提问留着。本机当时 20 只 Cursor 命令行助手、15 只就绪、5 只仍在执行中,resume 标识互不重复,不是同一条聊天被拉了两份。空闲回收默认 2 小时;托管数超过 12 时还会在进界面 / 新开托管前顺带关掉闲置 >10 分钟且非执行中的卡。刚开过不久、或仍在执行中的不会被压力回收。禁止的误修:把它们折进没开的 Cursor.app、当成 fork 风暴、或为了「进程变少」去关图形版 / 杀 ChatGPT 的 node。要马上减数量:在调度界面结束不需要的托管会话。性能分诊见 docs/PERFORMANCE_KNOWLEDGE_BASE.md 排查分诊第 5 条。
  • 会话名前缀 corral-,旧前缀 sc- 保留匹配:项目改名 sessionContinue → corral 之前创建的 sc-* 保活会话可能仍在用户机器上跑,_list_tmux_sessions 同时匹配两种前缀,annotate/回收对存量会话继续生效;新建会话一律用 corral- 前缀。注入托管会话的环境变量同理:CORRAL_RUNTIME/CORRAL_SESSION_ID 为新名,SC_RUNTIME/SC_SESSION_ID 继续注入兜底。
  • 无前缀脱离键 Ctrl-\keepalive.tmux.confbind-key -n C-\\ detach-client-n 表示不需要 prefix 就能触发)。选它是因为 tmux 接管终端后处于 raw 模式,Ctrl-\ 不会像普通终端那样触发本地 SIGQUIT;标准 Ctrl-b d 始终保留作为备用。新增/改绑定前确认没有和目标运行时 CLI 自身的快捷键冲突。
  • 已知边缘案例attach-session 发起瞬间目标会话恰好自然退出(tmux 报错退出),corral 不做特殊重试,用户重新打开一次 corral 即可(这时该会话已经不再显示"后台运行中",回车会走正常原生恢复路径)。
  • 直启子命令(corral claude 等)默认不再直接 execvp,而是带着 _DirectLaunch(plan + runtime_id + ident)进 TUI:真实终端且内嵌可用时,_run 在主循环前经 embed.host_session 把新会话托管进保活 socket 并聚焦右栏——保活包裹因此走 embed 路径(与界面内「新建会话」同一个调用点)。托管成功回调必须用 register_hosted_session(..., ident=direct.ident) 立即登记侧边栏占位卡、按当前工作目录归入项目并重建列表;不能只给右栏临时 dict、等扫描器发现真实历史。Codex 会在首条用户消息发出时才真正创建历史文件,旧做法会让侧边栏从直启到首条消息再到下一轮重扫才出现,真机曾延迟约 40 秒。真实历史经 liveness.annotate() 挂上同一托管名后,_merge_scanned() 会自动退役占位卡,不能另写替换逻辑。只有非真实终端、--no-keepalive 或内嵌不可用(无 tmux/被环境变量禁用)时才退回旧路径 keepalive.enabled()/keepalive.wrap_plan() + execute_launchwrap_plan 的第三个调用点,与 TUI 的 _launch() 复用同一套开关语义)。直启没有"已有保活会话"这个概念(每次都是全新会话,identkeepalive.new_session_ident() 现生成),所以不需要像 _launch() 那样先尝试 attach_plan踩坑(2026-08-15):corral cursor 进 TUI 后键盘仍在侧边栏 / 停在「+ 新建」_on_direct_hosted 必须像空白新建一样 show_hosted_group(..., focus_pane=_can_autofocus()),让意图跨过异步挂载;禁止在 cells()[0].embed_pane() 还是 None 时 set_focus 失败就放弃——真机挂载期间搜索框不可聚焦,默认焦点在列表,不带 focus_pane 的挂载收尾会把焦点还回去。恢复搜索框可聚焦要放在登记意图之后,免得 Textual 把默认焦点送回 #project-search。回归:DirectLaunchHostingTests.test_direct_launch_hosts_and_focuses_pane_without_stealing_focus_backtest_direct_launch_focuses_pane_even_when_list_already_has_focus

内嵌面板(embed.py)

界面层迁移说明(2026-07):界面已从手写 curses 整体换成 Textualembed.py 的 tmux 抓帧/输入转发/控制通道仍保持 UI 框架无关的架构边界,但其性能与生命周期实现会继续演进。旧版入口模块的 _run 主循环 + _draw_embed_paneui/main_screen.pyMainScreen + ui/embed_pane.pyEmbedPane widget 取代;PairPool(curses 颜色对池)被框架中立的 embed.cell_style(cell) -> rich.style.Style 取代;translate_key(curses键码)translate_textual_key(key字符串) 取代。下文凡是描述 curses/ncurses 内部行为的部分仅作历史存档;tmux/协议层结论仍适用,但以同节较新的 ControlChannel、Line API 和滚动约束为准。鼠标拖拽跨行选词 + 复制直接复用 Textual 文本选择:拖动高亮,抬起时 Screen 发 TextSelectedMainScreen.on_text_selected 有选区则 copy_to_clipboard(OSC 52);Ctrl+C 仍可再复制。无选区时 Ctrl+CEmbedPane._on_key 转发给托管会话中断(widget event.stop() 后 Screen BINDINGS 不会再执行,故中断判断必须留在 _on_key)。

  • 定位:与 keepalive.py 平级的运行时无关层。keepalive 管「把启动计划包进 tmux 保活」,embed 管「不 attach——用 capture-pane 拿画面、send-keys 送按键」,让 TUI 回车后退化成左侧会话列表(固定 ~39 列,ui/main_screen.pyLIST_PANE_WIDTH)+ 右侧会话现场(EmbedPane)。与保活共用 tmux -L corral-keepalive socket 和 corral-*/sc-* 命名空间:liveness.annotate() 状态标注、keepalive.reap_idle() 空闲回收、q 结束进行中会话,对内嵌会话全部照旧生效。适配器不感知本模块。键盘焦点跟随明确意图:回车打开 / 新建 / 直启托管成功后自动交给右栏对应格,浏览列表不抢;Ctrl+\ 交回列表,焦点在侧边栏时活着的实时格压暗提示输入未接管。滚轮与焦点无关。
  • 窄栏是卡片式多行布局:搜索框/新建项遵守「末行间隔」硬约定(见 AGENTS.md / 上文)。SessionListView 是固定头 + 会话滚动的外壳:#project-search 在列表外,+ 新建#sidebar-sticky,置顶与未置顶都在 #sidebar-scroll 里一起滚(置顶只改变排序,不冻在视口);指针在固定头上滚轮仍带动会话列表。光标停在组卡上时整组(组卡+成员)贴 -group-selected,激活会话再叠 -split-active。每个会话是高度 3 的 SessionCard(三行正文:首行「圆点 项目 标题」/ 运行时靠右 / 时间靠右);收起的会话组也保持三行,第三行用黄/绿/红圆点、短标签和数量汇总成员关注状态,展开时该行留白并由成员卡各自表达状态;组卡第二行的项目名与组名同为粗体,成员数弱化。NewSessionCard 高 2;#project-search 高 2。搜索框与新建项的间隔画在控件自身内并算进命中区,不要用 ListItem 的 margin/padding。状态仅用黄/绿/红单圆点与详情头文字表达,禁止恢复整行绿色标题;标题生成中不画 spinner。左栏 LIST_PANE_WIDTH=39;内层列表把滚动条占位收成 0;长标题按显示宽度截断并加 ...
  • runtime 名配色(单一来源):色表与样式串在 theme.pyRUNTIME_LABEL_STYLES / runtime_label_style(runtime_id)(按 runtime id,不是 display_name)。左栏 SessionCard、右栏详情头、对话预览里 assistant 整段(◆ Runtime: 正文 及续行)必须共用这一处,禁止再在 ui/ 里另写一份 hex。配色优先一眼可辨、不强制品牌色复刻——Cursor 品牌橙与 Claude 撞色,故 Cursor 用紫:claude=#D97757codex=#60A5FAcursor=#A78BFAkimi=#F472B6opencode=#34D399,未知回退 dim;展示用 bold <color>。新增 runtime 只加色表一行。用户消息整段用 bold cyan
  • 踩坑(2026-07-19 / 2026-07-21 / 2026-07-22):改了源码但 corral 仍是旧行为——~/.local/bin/corral 常见 shebang 指向 pipx venv,加载的是该 venv 的 site-packages 副本,不随 cli/ 源码自动更新。Cursor / 系统 python3 -c "import corral" 往往能 import 到仓库 cli/src/corral,单测「看起来已改好」,用户敲 corral 却仍是旧包。根治(开发机)bash scripts/dev-install.sh(对入口解释器 / pipx 做 -e editable);之后改 src/ 立刻生效。核对:corral --version / corral diagnosepackage_fileloaded_from_checkoutstale_source_warning;在仓库目录内启动 TUI 若加载了别处副本,stderr 会告警。仍须重启已打开的 TUI。pip show corral 的 Version 跨 Python / pipx 还可能误导。夹具截图若整图灰阶,先查 NO_COLOR(Textual Monochrome);docs/screenshots/capture.py 会在创建 App 前清除它。配色也可用真机 TUI 或 Pilot 下 SessionCard.render_line(0) 的 segment style 核对。命令清单见 AGENTS.md「本机入口」。
  • 踩坑(2026-07-21):SSH 上 corral 颜色「变丑」不是为了省带宽——Rich/Textual 按环境协商 color_systemCOLORTERM=truecolor|24bit → 真彩;否则 TERM-256color 结尾 → 256 色(hex 主题被量化,看起来发脏);再否则 → 约 16 色。OpenSSH 默认 AcceptEnv 常只有 LANG LC_*不转发 COLORTERM,远端因此掉到 256。这与网络负载无关,corral 主题/内嵌画面本身发的是真彩 hex,也禁止再做 _rgb_to_256。排查:远端 echo $TERM $COLORTERM;本机兜底可 export COLORTERM=truecolor/etc/profile.d/truecolor.sh,或 sshd AcceptEnv ... COLORTERM + 客户端 SendEnv。强制真彩在现代终端(iTerm2/Ghostty/kitty)经 SSH 通常安全,带宽开销可忽略。
  • 输入延迟的五道闸:① 控制模式通道embed.ControlChannel):聚焦 pane 时开常驻 tmux -C attach 子进程;修改类命令必须走通道,capture-pane/display-message 只读查询优先走同步 request()、通道失败才回退外部 fork;SGR 鼠标序列和多行 paste 仍走专用外部路径。pane 输出的 %output 唤醒抓帧,%pause 自动回 continue。② 没事发生不画;③ 画面文本没变不重复 parse_screen;④ Line API 只编译和刷新变化行;⑤ %output 事件驱动 + 慢速兜底轮询,抓帧在输出风暴下限速。死亡判定要求连续 3 次 capture 失败且 has-session 确认,防 tmux 瞬时超时偷走焦点。控制通道不会把窗口缩成自身尺寸,keepalive 的窗口策略无需为它调整。
  • 原生屏幕解析行与 Line API 必须成对演进(2026-07-22,真实终端自测发现)parse_screen_rows() 开启原生加速后返回 ParsedRow(text, spans, fingerprint),不再是 list[Cell]_row_to_strip_sync_strips 的首帧尺寸判断和新旧网格形状比较都必须兼容两种行形态;ParsedRow 的宽度用 Rich cell_len(text) 计算,不能调用 len(row)。旧实现只改了编译函数,漏改尺寸比较,导致每帧 TypeError 被抓帧线程兜底后继续重试,表现为左栏已显示“运行中(托管)”但右栏永久停在结束会话预览。单测 test_sync_strips_accepts_native_parsed_rows 覆盖两帧 diff,selftest.sh 覆盖真实 tmux 实时画面。
  • ControlChannel 的 ready/FIFO/close 是一组不可拆的协议约束:构造后必须先完整消费 tmux -C attach 自身的启动响应并确认 ready,业务命令才能进入队列,否则首个请求会错拿 attach 的 %end、后续响应整体错位。同步 request() 和火后不理的 command() 共用一个按写入顺序登记的 FIFO;waiter 登记与 stdin 写入/flush 必须在同一把锁内,reader 按完整 %begin…%end/%error 块依次出队,不能用时间戳猜跨线程响应归属。请求超时后通道必须关闭(响应是否迟到已不可知,继续复用只会错位)。close() 必须幂等,唤醒 pending 与 reader 已取出的 active waiter,并依次关闭 stdin、terminate/必要时 kill + wait 子进程、避开 reader 自己 join 自己、最后关闭 stdout;分栏关闭和应用退出都要走 close_channel(),不能留下孤儿控制 client 或僵尸进程。真机集成测 test_embed.ControlChannelIntegrationTests.test_channel_send_reaches_pane_without_fork 在共享机器高负载下偶发「数秒内画面未出现注入标记」超时——优先隔离重跑该用例,不要据此回退通道抓帧路径。
  • 光标锚定(IME 预览位置修复,2026-07 已在 Textual 版本补齐):curses 版本靠每帧把外层硬件光标 move 到 pane 内 agent 光标处;Textual 的等价接口是 App.cursor_position(官方文档明确写的用途就是"controlling the positioning of OS IME and emoji popup menus")。EmbedPane._update_app_cursor()pane_state() 拿到的 cursor_x/y/flag(widget 内局部坐标)加上 self.region.offset(widget 在屏幕上的绝对位置)换算成屏幕绝对坐标写入 self.app.cursor_position;聚焦(_on_focus)、抓到新一帧(_apply_capture/_apply_cursor_and_flags)、resize 时都会重新计算。真机验证方法(本机没有输入法,验证不了候选框肉眼位置,但可以验证它依据的底层坐标算得准不准):selftest.sh 里用一个不换行打印提示符的 fake 命令占住 pane,同时读外层 tmux 会话(corral 自己跑的那个)和内层托管会话(tmux 视角)各自的 #{cursor_x}/#{cursor_y},断言 外层x == 左栏固定宽度(39) + 右栏前空隙(1) + 内层x外层y == 助手顶栏(1) + 格标题(1) + 内层y——即验证 Textual 真实接管的终端硬件光标寄存器精确落在托管画面里 agent 光标应该在的屏幕位置。光标不可见(cursor_flag=False)时 _cursor_local_offset() 返回 None,不更新 app.cursor_position(沿用上一次位置,不锚到左上角或底部帮助行)。
    • 只锚定位置还不够,必须让外层真实光标「可见」——否则中文根本打不进去(2026-07 真机反馈"内嵌 agent 打不了中文"后补的关键修复)App.cursor_position 只负责把光标移到哪,但 Textual 全屏运行期默认在驱动启动时写一次 \e[?25l 把真实硬件光标藏掉、整个运行期都不再显示(只有退出时才 \e[?25h),全靠 Input/TextArea 自己画一个假光标块。位置算得再准,被藏起来的真实光标对 IME 也没意义——macOS 等系统的输入法靠可见的真实光标决定候选词窗口位置、甚至据此决定要不要激活中文合成,真实光标不可见时用户在内嵌 Agent 里连中文都打不出来(不是候选框错位,是压根不合成)。这正是"位置验证通过但 IME 仍不工作"的盲区:老的 selftest.sh 只断言了 #{cursor_x}/#{cursor_y}(位置),没断言 #{cursor_flag}(可见性),位置对了就误判通过。修法:EmbedPane._set_real_cursor(visible) 在聚焦 pane 且有可见光标时显式写 \e[?25h 打开真实光标(Textual 每帧只移动、不会重新隐藏,写一次即保持),失焦/会话结束/托管程序自己藏光标时写 \e[?25l 收回;_update_app_cursor 在设置 app.cursor_position 的同时调用它,_on_blur/_apply_dead/on_unmount 负责收起。效果等同 tmux/screen attach 时把外层光标停在活动 pane 的光标处——那正是嵌套终端里 IME 能正常工作的原因。真机验证selftest.sh 已补 #{cursor_flag}==1 断言(聚焦内嵌 pane 时真实光标必须可见);Pilot 侧 test_focus_shows_real_cursor_blur_hides_it 断言 _real_cursor_shown 随焦点翻转。教训:验证"某个依赖底层终端状态 A 的上层效果 B"时,只断言 A 的一个维度(位置)不能证明 B 成立——A 的另一个维度(可见性)同样是 B 的必要条件,漏断言哪个都会放过真实 bug。
  • 托管状态双通道liveness.annotate() 靠 pid 祖先链匹配(跨进程有效),SessionStore.hosted 记本进程内刚内嵌的会话(比 pid 注册快、对不注册 pid 的程序也有效);_merge_scanned 先 annotate,没匹配上的用 hosted 兜底并校验存活。q 结束会话时两处都要清,并立刻把 live/pid 置为已结束、记入 _force_ended——否则列表会先从「运行中(托管)」闪成「运行中」(只清了托管名、上次扫描的 live 还在),进程尚未退出时下一轮扫描仍报 live 也会再闪一次;确认扫描到 live=False 后才解除强制。已知残留风险(2026-07 真实实例):高负载/竞争下 keepalive_name 标注可能瞬时丢失(annotate 匹配失败 + hosted 的 is_alive 超时误报同时发生),此时回车会走 _embed_open 新建出第二个同会话进程——保活 socket 上出现过 sc-kimi-session_(旧命名)与 corral-kimi-session_(新命名)并存、两个进程抢同一份会话文件的真实案例;同一竞争还会导致 q 的确认弹窗不出现(端到端自测偶发过一次,加状态抓取后未复现)。根治方向(未做):回车新建前若同名/同会话已有存活托管,强制复用而不是新建。
  • tmux 是软件级硬依赖,且有版本下限 3.2:TUI 与直启子命令在启动时 _require_tmux() 检查,缺失即报错退出并提示安装;agent_api 只读子命令(list/search/show/context/describe)不检查——它们不拉起任何进程,annotate() 在无 tmux 时本就静默跳过。改这里之前想清楚:不要因为"优雅降级"把无 tmux 的半残启动路径加回来。版本下限(2026-07 补)host_session()/keepalive.wrap_plan() 用的 new-session -e 环境变量注入(托管会话的 CORRAL_RUNTIME/CORRAL_SESSION_ID 等元数据唯一注入点)要求 tmux 3.2+(2021-04 发布),ControlChannel 依赖的 pause-after 流控(%pause)同样是 3.2+ 引入。旧发行版(如 Ubuntu 20.04 的 tmux 3.0a、Debian 11 的 3.1c)用户装完 corral,若不检查版本会在首次创建托管会话时拿到一个和版本无关的笼统 EmbedError,很难联想到升级 tmux。_require_tmux()cli.py)在 shutil.which 通过后追加 embed._tmux_version() >= embed.MIN_TMUX_VERSION 判断,报错明确点名当前版本和最低要求;版本解析不出时不阻断(宁可信任已通过的 which 探测,让真实失败在后续调用里自然暴露)。embed.MIN_TMUX_VERSION = (3, 2)supports_theme_report() 用的 (3, 5) 背景色注入下限是两件独立的事——前者是硬性拦截,后者是软性降级(探测不到就退回文档兜底,不阻断启动),改任一个都不要混到另一个的判断里。
  • 渲染为什么不自己写终端模拟器capture-pane -p -e 输出的就是 tmux(它本身就是终端模拟器)渲染好的当前画面加 SGR 颜色序列,embed.parse_screen 只需一个 SGR 状态机落格。Cell.fg/bgint | tuple[int,int,int]:SGR 38/48;2 原样保留 RGB,cell_stylerich.color.Color.from_rgb 直通真彩色——禁止再加回 curses 时代的 _rgb_to_256 量化(会把托管 agent 的渐变/主题色打成 256 色块)。字符宽度统一走 rich.cells.cell_lenembed._char_widthcorral._char_width/_text_width 同一张表),自写 wcwidth 表会导致列表截断与内嵌画面 CJK/emoji 对齐不一致。curses 端曾用 PairPool 分配 init_pair该问题在 Textual 版本里已不存在——颜色直接变成 rich.style.Style。实时画面使用 Textual Line API:按行缓存 Strip、比较新旧 Cell 行,只重编译并刷新变化行;render() 仅作框架内部/既有测试的兼容入口,必须复用 render_line() 结果,不能再维护第二套渲染逻辑。
  • Line API 的字符下标、选区坐标和静态详情失效必须一起维护embed.row_text_and_spans() 的样式 span 是 Python 字符串下标,不是终端 cell 坐标;跳过宽字符 continuation cell 后,索引仍要按 len(cell.ch) 增加,因为组合字符会和基础字符合在同一个 Cell.ch(例如 e + 组合重音长度为 2),固定 +1 会切掉后续文本。自定义 render_line() 返回的 Strip 必须 apply_offsets(0, y) 提供行坐标,否则 Textual 拖选会把整个 Widget 误判成全选;选区高亮在渲染时动态叠加,不能写进基础行缓存。Textual 的选区坐标系是"字符索引"(字符串下标),不是 cell 列,_apply_selection 裁切前必须把字符索引换算成 cell 列selection.get_span(y) 返回的 (start, end) 是字符下标(compositor.get_widget_and_offset_at 逐字符 get_character_cell_size 推进最终得到的是字符 offset,apply_offsets 给段的基址也是累计字符数),而 Strip.crop 按 cell 列裁切。CJK/emoji 一个字占 2 列,直接把字符索引当 cell 列交给 crop 会有两个后果(2026-07-20 headless 复现):① 高亮宽度按字符数缩水,中文选区只框住一半;② 裁切边界落在宽字符中间时该宽字符被 crop 整个丢弃、渲染成空格(真机反馈"从头拖到尾只高亮两个半、还有个字消失了")。修复:_apply_selection 里用 cell_len(text[:start])/cell_len(text[:end]) 把字符索引换算成 cell 列再裁切。踩坑记录:曾错误地把 offset 元数据改成 cell 基址(_apply_cell_aware_offsets)想在源头修,但那对单段行是 no-op(段基址为 0,两套坐标相等)、根本没生效,对多段行反而破坏了 Textual"段基址+段内字符"的一致性,已回退成 Textual 自带的 apply_offsets——正确的修法只在裁切那一步做字符→cell 换算,不要动 offset 元数据的坐标系。
    • 选区高亮不能整段套 get_component_rich_style("screen--selection"),否则会把选中的文字整个盖住看不见(2026-07-20 真机 bug,headless 启动真实 app 打印样式值确认根因):Textual 默认主题的 screen-selection-foregroundtransparent(alpha 0),语义是"保留原文字前景色、只给背景着色";但 get_component_rich_style 会把这个 transparent 前景预解析成一个具体颜色,实测这个值恰好等于选区背景色(都解析成 #094472),于是整段 apply_style 后前景==背景、文字隐形。Textual 自己的渲染路径(Content.render_segmentsline.stylize)用的是 textual.style.Style(保留 alpha 语义,transparent 前景=不改前景),这里在自定义 Line API 路径上用 _selection_style() 手动复刻:读 textual.style.Style.from_styles(...) 判断前景 alpha,transparent 时只取 bgcolor、保留每个 Segment 原前景;确有前景色的主题才连前景一起套。样式按主题名缓存,不在 render_line 热路径每行每帧重解析。改动内嵌面板的选区渲染时,务必 headless 启动真实 app 打印解析后的 color/bgcolor 实测,不要靠肉眼看颜色猜。静态详情/占位页按状态和尺寸缓存后,标题缓存、会话状态、摘要、列表重扫或 resize 变化都必须调用 invalidate_detail();详情 renderer 延迟执行时还要按稳定会话键重新解析 store 最新对象,不能继续展示闭包捕获的旧 dict。
  • 输入路径的三个关键设计:① curses 版本 TUI 必须从 cbreak 改 curses.raw()——否则 pane 聚焦时用户按 C-c 想打断 agent,SIGINT 会杀掉 corral 自己;raw 模式下 C-(0x1C) 才能作为「焦点回列表」的普通按键读入(和保活 tmux 配置里 C-\ detach 是同一肌肉记忆),列表/侧栏里 C-c(字节 3)显式映射为退出、C-z(字节 26)为挂起 corral。Textual 版本没有 cbreak/raw 的选择问题:Textual 自己管理终端模式(应用启动即接管为适合自身事件循环的模式),Ctrl-C/Ctrl-\ 都作为普通按键事件(event.key == "ctrl+c"/"ctrl+backslash")送到当前聚焦的 widget;EmbedPane._on_key 专门拦截 ctrl+backslash 转发焦点请求(见「输入转发」小节),其余按键(含 ctrl+c)转发给托管会话,event.stop() 阻止再冒泡到 MainScreenctrl+c 退出绑定——效果与旧版一致(pane 聚焦时 C-c 打断 agent 不会误杀 corral 自己),机制不同。② 可打印字节(含 UTF-8 高位字节)先按字节攒批、解码成字符串再一次 send-keys -l,避免每键一个 tmux 子进程;IME 提交的中文因此不会散成乱码——这条在 Textual 版本里对应 event.is_printable and event.character 分支直接调用 embed.send_literal,Textual 已经把按键解码成完整字符再交给事件处理,不需要 corral 自己攒字节。③ 粘贴走终端 bracketed paste:curses 版本 TUI 启动时开 \e[?2004hmain() 在 wrapper 返回后关),pane 聚焦时识别 \e[200~/\e[201~ 包裹的正文;Textual 版本里这条由框架原生处理并派发为 events.Paste 事件,EmbedPane._on_paste 直接拿到解析好的 event.textset-buffer + paste-buffer -p 整段注入,目标程序按 bracketed paste 接收,不需要 corral 自己解析 \e[200~/\e[201~ 包裹序列。
  • 必须关掉 Textual 默认的 Kitty 键盘协议,否则 iTerm2/Ghostty/kitty 里内嵌 Agent 打不了中文(2026-07 真机反馈"SSH 到远端跑 corral、iTerm2 里内嵌 Agent 无法输入中文"后定位的真正根因)cli.py 在任何 import textual 之前 os.environ.setdefault("TEXTUAL_DISABLE_KITTY_KEY", "1")根因:Textual 的 linux_driver 启动时默认向终端发 \e[>25u25 = DISAMBIGUATE(1) | REPORT_ALL_KEYS(8) | REPORT_ASSOCIATED_TEXT(16))开启 Kitty 键盘协议。支持该协议的终端(iTerm2 / Ghostty / kitty)收到后进入"把按键当转义码原样上报"模式,绕过操作系统输入法(IME)——用户在内嵌 Agent 里打 nihao 时,终端把 n/i/h/a/o 直接作为 CSI-u 事件发给应用,IME 根本没机会介入弹候选词,中文压根打不出来。关键排查路径(记下来免得又走弯路):① 先怀疑并逐一排除了 corral 侧——用真实 corral 进程 + tmux send-keys -l 灌中文(含逐字节拆开模拟 SSH 网络分片切断多字节 UTF-8),验证 _on_key → send_literal → tmux 转发链完全正常,中文能落到 agent;② 又验证 Textual 的 XTermParser 对中文(无论普通 UTF-8 还是 Kitty CSI-u 关联文本形态)都能正确解出带 character 的 Key 事件——解析和转发两半都没问题;③ 决定性线索是用户反馈"同一个 SSH、同一个 iTerm2,nano 能打中文、corral 不能"——两者唯一差别就是 corral(Textual)开了 Kitty 协议而 nano 没开。这类"输入根本进不来"的问题要优先排查终端协议协商(Kitty keyboard / DA / DECRQM 这类应用启动时主动发给终端、改变终端行为的私有序列),而不是死磕应用内部的解析/转发代码——后者用 send-keys 灌字节很容易自证清白,真正的坑在"终端被应用切成了另一种模式"。为什么可以整体关掉:corral 本质是把外层终端输入转发给托管 tmux 会话的终端复用器(类似 tmux/screen,它们默认也不对外层开 Kitty 协议),普通字节 + 标准转义序列已够用;Kitty 协议的按键消歧义好处(区分 Ctrl+I/Tab、上报按键释放等)对 corral 边际很小,却实打实破坏 IME。真机之外能做到的最硬验证:用 pty.fork() 跑真实 corral 抓它写给终端的原始字节流,断言默认不再出现 \e[>25u(强制 TEXTUAL_DISABLE_KITTY_KEY=0 作对照时该序列出现)——test_ui.KittyKeyboardProtocolTests 锁住开关状态,selftest.sh 全部按键路径在关闭协议下仍 14/14 通过(关掉不影响 Ctrl+\ 回列表、Ctrl+C、方向键、可打印字符转发)。⚠️ 必须在 textual 导入前设置:textual.constants.DISABLE_KITTY_KEY 是导入期求值的 Final 常量;用 setdefault 让想恢复协议的用户能显式设非 "1" 值覆盖。
  • 踩坑(2026-08-14):分屏画面只占格子约 1/5,外层缩放也不自愈。根因不是抓帧裁切,而是托管 tmux 窗口停在创建期窄宽(常见 40 列),corral 却按格子全宽解析、右侧补空白。三处叠加:创建尺寸用整除估算逼出启动期 resize(agent 漏 SIGWINCH 后 tmux 不补发);控制通道 resize-window 只保证写入成功,%error 既不回退也不重试,_host_size 还把「请求过」当成「已落地」;预测余数分配与 Textual 不一致时 _capture_size_override 永不清除。现场可用 tmux -L corral-keepalive list-panes -a -F '#{session_name} win=#{window_width}x#{window_height}' 区分:win=40x… 明显小于右栏格宽即窗口真窄;win=80x… 且格子约 240 列是控制通道默认 80 列把窗打回约 1/3(2026-09-03,见上条 window-size manual);窗口正常则是解析宽用了过期 override。修法见界面节同条;不要再让 host_pane_size// count,也不要在「预测宽 == Resize 宽」时才清 override。
  • 「连接中…」卡死的状态机约束(2026-07 两次用户实报后补齐;当前实现位于 ui/embed_pane.py:产品层不允许出现任何“连接中”中间页——连接/抓帧是后台实现细节,已有会话首帧到达前立即显示扫描层已有详情,新启动且无详情的会话立即显示空白终端画布,随后无缝替换成实时画面。老 curses 版曾因 grid=None 时提前记录 last_text,静止画面之后永远被当作“未变化”而不再解析;迁移到 Textual 后又出现同类但更隐蔽的竞态——后台重扫/重复点击会再次 focus_session 同一个会话,前台无条件清空 _grid,抓帧线程的局部 last_text 却仍认为画面没变,于是实时画面永久不出现,调整窗口大小只是碰巧改变尺寸/文本后打破缓存。当前不变量必须同时保持:① render() 永远不渲染连接占位文案,focus_session 接收可选的即时详情渲染器;② focus_session 对“同名且已有有效画面”幂等,不清帧;确需失效(切换会话、详情页快速往返、尚无有效画面)时提升 _capture_generation,抓帧线程按版本强制重解析,即使它没来得及观察中间的 session_name=None 也能识别;③ 帧缓存键包含“版本 + 回滚偏移 + 宽高 + 文本”,窗口变化即使文本相同也要重排;缓存键只能在解析和主线程回写都成功后提交,任一步异常都要重试同一帧;④ _apply_capture/光标/死亡等所有跨线程回调携带“版本 + 会话名”并在回写前校验,旧会话已排队的回调不得覆盖新视图;⑤ 抓帧循环全包 try/except,异常写 ~/.cache/corral/embed-error.log(含 traceback,256KB 截断)后继续,不能让后台线程静默死亡。回归测试至少覆盖:首帧前即时内容、重复选中静止会话、详情页快速切回同名会话、旧回调延迟到达、单帧解析异常后自动恢复;真实 tmux 冒烟继续覆盖侧边栏显示。
  • 终端背景色注入(深/浅主题检测修复):tmux 对 pane 内的 OSC 11 背景色查询的应答取决于有无 client——无 client 时石沉大海(查询超时),有 client(内嵌场景恒有控制 client)时按 client 默认值应答黑色,agent 因此在浅色终端上被误判成深色主题(这不是内嵌引入的,全屏 attach 一样)。main() 趁 Textual 接管终端前(ui.app.run_app() 之前,_probe_osc_colours() 本身不依赖 curses,未随迁移改动)_probe_osc_colours() 向外层终端查询 OSC 10/11 拿应答原文(非 TTY/不应答则 None;测试钩子 CORRAL_OSC_REPORT=hex)。
    • 踩坑(2026-07-23):探测超时太短 + 不清输入队列 → 启动泄漏 OSC 应答,搜索框乱码、会话列表被过滤空(真机反馈:启动先闪过一行 ...rgb:xxxx/...,进 TUI 后搜索框乱码且侧边栏一条会话都不显示)。根因两处叠加:① v0.24.0 性能优化把 _probe_osc_colours 超时从 1.2s 砍到 60ms,tmux/SSH/慢终端下 \x1b]11;rgb:... 应答晚于 60ms 才到;② 探测结束用 TCSADRAIN 恢复 termios 却没清空输入队列。晚到的应答字节残留在 tty 输入队列,被随后接管的 Textual 当键盘输入注入有焦点的搜索框——on_input_changed 实时过滤会话列表,乱字符把列表整个筛空。修法(v0.24.3,两手都要)_probe_osc_coloursfinally 恢复 termios 后无条件 termios.tcflush(fd, TCIFLUSH) 清残留(根治泄漏,代价是极端慢终端可能拿不到背景色、退回默认主题,可接受);超时放宽到 0.25s 覆盖 tmux/SSH 往返,确保应答在 Textual 接管前收完——读到应答即返回,快终端不受上限影响,不应答终端最多白等 0.25s 且与后台扫描线程并行。只做一个不够:只 flush 不提超时,应答仍在 flush 之后才到、照漏;只提超时不 flush,读循环凑够 rgb: 计数就退出会留下没读完的尾巴。回归测试 test_ui.OscProbeFlushTests(写线程在探测进入读循环后才送应答+尾巴,断言探测返回后输入队列已空;去掉 flush 即失败)。注意 tty.setraw 默认 when=TCSAFLUSH 会清输入——写这类 pty 测试时应答必须在 setraw 之后注入,否则被 setraw 自己冲掉,测不出真实场景。
    • 踩坑(2026-07-23 续):corral cursor / 直启仍复现——TMUX passthrough 晚于 flush + 搜索框默认焦点。v0.24.3 修了「队列里已有尾巴」;但在 TMUX 下探测会额外发一对 DCS passthrough 查询,读循环仍以 rgb: 计数 ≥2 为提前退出条件——外层 tmux 缓存应答先到(刚好 2 段)就结束,随后 tcflush之后才到的真实终端 passthrough 应答照样漏进 Textual。普通 corral 挂载后立刻 SessionListView.focus(),泄漏大多打到列表而非筛选框,症状被掩盖;直启(corral cursor 等)故意不先 focus 列表(否则会把最终焦点从内嵌面板抢回去),Textual 默认焦点落在 #project-search,泄漏直接变成筛选条件、侧边栏被滤空——用户看到「普通启动好了、直启又坏」。修法(v0.24.4,三手):① 凑齐首对应答后再开 settle 窗口(tmux 0.12s / 非 tmux 0.03s)继续读,把路上的 passthrough 吃掉再 flush;② 直启挂载期间 search.can_focus = False,托管结束(成功/失败)再恢复并清掉已灌入的 OSC 垃圾;③ on_input_changed 若发现 \x1brgb: 特征直接清空(兜底极晚泄漏)。回归:OscProbeFlushTests.test_tmux_settle_drains_late_passthrough_pairDirectLaunchHostingTests.test_direct_launch_disables_search_focus_until_hostedtest_search_rejects_osc_leak_garbage
    • 踩坑(2026-08-17):TerminalThemeParser 把孤立 Esc 键扣成「待补全应答」,真实终端上 Esc 要按两次才生效(症状:清空搜索过滤、退出界面都要按两次 Esc;selftest 稳定卡死在「Esc 清空搜索」步;v0.24.132 修复)。根因:_trailing_marker_prefix 把结尾单独的 \x1b 也当成可能的 OSC 11 / CSI 997 应答开头暂存在 _theme_pending,而 XTermParser 的 ESCAPE_DELAY 超时根本看不到这个字节,只有下一个按键到达才连带放出。Pilot 注入按键事件绕过字节解析,单测测不出来;只能用真实 tmux 或字节级单测守。修法:解析器 tick()(驱动每轮 select 后调用,约 0.1s)把扣超过 _PENDING_FLUSH_AGE(0.05s)的未确认前缀放行——单独的 Esc 直接生成 Key("escape")(免再等一轮 ESCAPE_DELAY);已确认是应答开头的暂存(如 \x1b]11;rgb: 缺终结符)不参与超时放行。真实应答拆包余下字节早就在 pty 缓冲里、下一个 tick 内经 feed 补齐,split_at_every_byte 回归不受影响。排查这类「真实终端与 Pilot 不一致」的按键问题,从字节层下手:在 XTermParser.feed 入口落盘原始字节,对比 tmux send-keys 发出的内容;测「输入字节何时到达应用」不要用 cat -v 的回显判断--应用开启备用屏 / 同步输出后 tmux 会把回显冻住,看起来像「字节没送达」的假象(本次弯路:先误判为 tmux 扣字节,逐个终端模式二分全不命中);正确姿势是独立探针进程置 raw 后读 stdin、带时间戳写文件。回归:RuntimeThemeParserTests.test_lone_escape_is_released_by_tick_not_held_forevertest_stale_unconfirmed_prefix_flushes_to_original_parser
    • corral 自身界面的深浅色是另一件独立的事:CorralApp.on_mountcorral._background_is_light(osc_report)(解析 OSC 11 的 rgb:RRRR/GGGG/BBBB,ITU-R BT.709 亮度公式,阈值 0.5)决定 self.theme = "corral-light" 还是 "corral-dark"(冷静工作台主题,不是 Textual 默认的 textual-*)——这条 2026-07 才补上,之前完全没接,Textual 默认主题在浅色终端下配色不对(真机反馈)。筛选框与列表选中的层级约定见 docs/TERMINAL_UI_KNOWLEDGE_BASE.md「壳层配色层级」。
    • 踩坑(2026-07-25):只在启动时决定主题,日落后 iTerm2 已变黑但长驻 corral 仍停在浅色,重开才正常。Textual 的主题系统支持运行时赋值,但不会自动感知外层终端背景变化;iTerm2 支持 OSC 11 查询,却不支持 DEC 2031 主动通知。因此 ui/terminal_theme.py 在 Unix Textual 输入解析入口提取两类应答:支持标准的终端发 CSI ?997;1n/2n 时立即切换;所有终端每 2 秒再做一次无阻塞 OSC 11 查询兜底,处于 tmux 时同时发 DCS passthrough,避免只读到 tmux 启动时缓存色。禁止用后台线程直接 os.read(stdin) 轮询——Textual 自己已有输入线程,两者会竞争并随机吃掉用户按键;也不要调用启动期 _probe_osc_colours(),它会改 tty 模式并 flush 输入,只适合框架接管前。收到新背景后必须同步四处:corral 壳层主题、主屏保存的应答、已挂载面板的透明底色、后续新托管会话的主题报告;仅改 self.theme 会让右栏仍透出旧背景。回归:RuntimeThemeParserTests 覆盖应答分块和普通按键不丢,AppThemeTests.test_running_app_switches_theme_when_terminal_background_changes 覆盖壳层、当前面板与后续报告同步。
    • 内嵌 pane 底色必须垫成外层终端真实背景色(2026-07 修,真机反馈"内嵌 agent tui 背景变中性灰"):托管 agent 画面里绝大多数格子是"默认背景"(tmux capture-panebg=-1cell_style 映射成 bgcolor=None → Rich 透明),Textual 会把它们透到 widget 底色。EmbedPane 若不显式垫底,透出的就是 Textual 主题的 $background(textual-dark 下是一种中性灰蓝),整块内嵌画面因此看着发灰——老 curses 版是天然透到终端真实底色的(use_default_colors()-1),这是迁移引入的回归。修法:EmbedPane.on_mountcorral._background_rgb(osc_report)(复用 _background_channels 解析 OSC 11,输出 #rrggbb)拿到外层终端真实 RGB,self.styles.background = bg 垫到面板上,默认背景的格子就落在真实终端底色上、和外层无缝衔接。探不到 OSC 11(osc_report=None)时不设显式底色、退回 Textual 主题灰(降级可接受)。回归测试 test_ui.AppThemeTests.test_embed_pane_background_matches_real_terminal_bg 用 Pilot 断言 pane.styles.background.rgb 等于注入的 OSC 11 RGB。注意这条只解决视觉底色,和上一条 _background_is_light 决定 corral 自身主题、下一条 report_theme 注入让 agent 自己检测深浅,是三件独立的事,别混。
    • 托管 agent 自己的深浅色检测注入链路:探测结果经 run_app(osc_report=...) 传进 MainScreen/EmbedPane关键设计(2026-07 真机排查修正):注入必须在 embed.host_session() 创建会话的同一次调用里完成,不能留到调用方后续聚焦面板时(EmbedPane.focus_session)才做——refresh-client -r 只影响"pane 尚未被回答过"的后续查询,一旦某次查询已经被 tmux 用默认猜测值(纯黑)答复过,那次查询的结果就定死了,之后再注入不能让已经用掉错误答案的进程回头重查一遍;真实 agent 常在启动的头几百毫秒内自己查一次 OSC 11,如果注入只在"用户聚焦面板"这一步才发生(中间还隔着 open_channel/resize 等多个 Python 级往返),大概率已经错过窗口。当前实现:host_session(..., osc_report=...) 创建会话成功后立即调用 open_channel(name) + report_theme(channel, osc_report),且通道必须保持打开、不能注入完就关——refresh-client -r 依赖"当前有控制模式客户端连接着"这个前提,真机验证过一次性通道(注入后立刻 channel.close())比完全不注入还差(之后一次都查不到,不是查到默认值,是彻底拿不到应答);也验证过更激进的"先起占位命令、注入、再 respawn-pane -k 换成真实命令"方案(试图把窗口压缩到真实程序完全没机会先查),结果是 respawn-pane 会让 pane 拿到全新的 pty,把刚注入的颜色状态一并清空,比现在的方案更差,已放弃。open_channel() 复用同一个全局单例通道时,若已有相同名字的通道存在会跳过重建但更新 on_output 回调(不这样做的话,host_sessionon_output=None 建的通道会一直卡在 None,EmbedPane 后续聚焦时的事件驱动重绘失效、退化成慢速轮询)。host_session 创建时经 new-session -P -F '#{pane_id}' 顺带取回 pane_id(记入 _pane_ids),供上述注入寻址。实测边界(2026-07 在 tmux next-3.7 + Claude Code v2.1.207 上逐字节验证;观测手段:tmux pipe-pane -t <会话> 'cat > <文件>' 抓 pane 程序原始输出流,agent 启动时发出的查询序列原样可见):① Claude Code 启动时双通道查询——\e[?2031h 订阅主题通知 + DCS passthrough 包装的 OSC 11(tmux allow-passthrough 默认 off 被丢)+ 裸 OSC 11(tmux 应答,注入值走这条路);② 注入只影响之后启动的 agent——已运行的 agent 不重查,refresh -r 后 tmux 会向订阅 pane 推 \e[?997;1n(dark)/\e[?997;2n(light) 通知,但 Claude Code 实测不响应(注入浅色后 user pill 依旧无色),旧会话只能重启或在 agent 里手动固定主题;③ Claude Code 的 user 消息背景 pill 在 tmux 里天然不画(启动前注入白色也一样),与 Codex issue #19741 同款,是 agent 侧行为不是 corral 渲染错误;④ tmux 把注入的 16-bit RGB 归一化成高 8 位重复格式(abcd/1234/5678abab/1212/5656),断言/调试时别按原值比对;⑤ Kimi Code 查的也是裸 OSC 11(passthrough 包装只用于 DA 查询)——注入米白(fae0)后启动的 kimi 界面实测全部变为深色文字(浅色主题),注入对 kimi 端到端有效;未注入时它是近白字(深色主题误判),用户实报的「白底上白字」即此。
    • 踩坑(2026-07-25,这条老 bug 的真正根因):refresh-client -r 的参数里只有第一条 OSC 序列生效,整串灌进去 = 只注入了前景色。真机症状:iTerm2 白天模式 → corral 自身正确切 corral-light,但内嵌起的 agent(Claude Code / Codex / Kimi)全部渲染成深色主题;Mac 本机直接开 corral 与 SSH 到远端开 corral 一样复现(与终端、与网络路径无关,纯代码 bug)。根因:_probe_osc_colours() 返回的是「OSC 10 应答 + OSC 11 应答」的拼接串(先查前景后查背景),而 report_theme 把整串当一个参数交给 refresh -r <pane>:<串>——tmux(next-3.7 实测)只解析第一条 OSC 序列,其余整段丢弃,于是真正落地的是 OSC 10(前景色);pane 的背景色停在 tmux 的默认猜测纯黑,agent 查到黑底 → 判深色。浅色终端上前景恰恰是黑色,所以症状是「越是浅色终端越确定变深色」。注意 corral 自身界面不受影响:_background_is_light 从同一串里按 11;最后一段,解析得到的是正确的浅色——界面对、pane 错这个错位正是本 bug 长期被误判为"agent 侧不识别"的原因。修法(v0.24.10)theme._split_osc_report(report) 把应答拆成 (背景 OSC 11, 前景 OSC 10) 两条独立序列,report_theme两条 refresh -r 命令发、背景色先发(agent 随时可能在两条命令之间发起查询)。实测规则记录:单条注入 OSC 10 不会影响 pane 的 OSC 11 应答(tmux 确实区分 10/11,不是混用一个槽位),两条独立命令后发的会覆盖先发的;tmux next-3.7 不再做 16-bit → 高 8 位重复的归一化(fafa/e0e0/d0d0 原样返回),与上面 ④ 在 3.5a 上的观测不同,跨版本断言要按实际版本核。验证方式(可复现):托管一个 codex 会话并注入浅色,capture-pane -p -e 抓帧看输入框背景 SGR——修复前 48;2;30;30;30(深),修复后 48;2;240;240;235(浅),同一份浅色应答 A/B 对照。验证时的选型坑:想用真实 Claude Code 做 A/B 对照会被它自己的一次性确认弹窗挡住(陌生目录问「是否信任」、带跨目录 CLAUDE.md 导入的目录问「是否允许外部导入」),画面停在纯文字提示、根本没有带背景色的输入框可比对,替它点确认又会往用户配置里写信任记录;改用 codex 在一个已信任目录里托管,起来就能在输入框看到明确的背景色 SGR,A/B 对照最省事。纯机制验证(不涉及具体 agent)用一个循环查询 OSC 11 并把每次应答带时间戳写文件的小探针脚本即可,能同时看出「注入是否生效」和「相对 agent 首次查询的时序」。回归测试:test_session_scanning.SplitOscReportTests(拆分规则)+ test_embed 的两条主题注入测试已改用真实形态(OSC 10 在前 + OSC 11 在后)的应答——老测试全用单条 OSC 11,恰好绕开了这个 bug,这是它能活这么久的直接原因;以后写这类测试一律用探测函数的真实输出形态

以下四条鼠标兼容记录(能力边界、列表页 SGR 降级、macOS 旧 curses 根治、向下回直播规则)全部描述 curses/ncurses 时代的手写鼠标协议协商mousemask/KEY_MOUSE/BUTTON5_PRESSED/SGR 1006 主动协商/_apply_mousemask/_read_sgr_mouse 等符号已随迁移整体删除)。Textual 用自己的终端输入驱动统一处理跨平台鼠标协议协商与解析(events.MouseScrollUp/MouseScrollDown 等是已经归一化好的事件,不需要 corral 自己判断 BUTTON5 是否可用、要不要主动请求 SGR 1006),这一整类「同一代码 Mac 和 Linux 表现不一致」「加粗/协议协商炸掉点击滚轮」的坑随迁移被结构性消除。以下四条保留仅供追溯旧实现踩过的坑;ui/embed_pane.py 的当前实现见小节末尾新增的「Textual 版本鼠标处理现状」。

  • (历史)鼠标在面板内的能力边界:mousemask 订阅滚轮 + 左键按下/抬起/拖动(不订阅会被 ncurses 在队列层滤掉,连丢弃的机会都没有)。KEY_MOUSE 必须先按坐标统一路由,不能只在右侧 pane 聚焦时处理:光标在左侧时,滚轮独立滚动会话列表的视口,不改变当前选中会话;键盘导航或点击某张卡片时才恢复“选中项始终可见”。落在右侧 pane 时,滚轮才进入回滚逻辑。右侧滚轮默认由 corral 自己处理应用层历史偏移;仅当停在直播画面(偏移为 0) pane 内程序确实申请了 SGR 鼠标上报(mouse_any_flag+mouse_sgr_flag)时,才把滚轮转成 SGR 序列直达内层程序(2026-07-18 用户要求:程序自己要滚动信号就给它)。实测 Claude/Codex 在托管 pane 里都不申请鼠标(两机 mouse_any=False),这条转发路径日常不生效 (此实测结论已过时:Claude Code 自 v2.1.88 起默认全屏渲染并申请鼠标捕获,2026-07-19 实测 2.1.214/2.1.215 托管会话全部 mouse_any=True,转发路径已成为主路径——见上方「滚动卡顿第二波」条目);已翻进回滚历史后无条件由 corral 收敛,保证「下滚回直播」永远可达。需要完整鼠标点选内层程序时,当前版本不提供 e 全屏逃生路径(已删除);只能依赖终端修饰键拖选或后续产品再定方案。点击/拖拽刻意不转发——ncurses 会把快速连续的 press+release 合并成 CLICK(未订阅即整个丢弃)、press+drag 合并成 motion,行为碎片化到无法承诺语义(本机实测两种合并都复现);另两个实测点:Python curses 没有 BUTTON1_POSITION_CHANGED 常量(getattr 兜底返回 0,motion 位根本进不了 mask),未订阅的鼠标序列会被 ncurses 整个吞掉、不会漏进键盘通道变成垃圾按键;macOS 自带 ncurses 5.x 还不导出 BUTTON5_PRESSED,向下滚轮会伪装成 BUTTON2_PRESSED(有的终端还会附带 REPORT_MOUSE_POSITION),因此必须订阅并把中键按下视作向下滚动,否则事件在队列层被滤掉,回滚只能向上不能向下。

  • (历史)列表页原始 SGR 滚轮降级(2026-07 用户实测):不能只靠 KEY_MOUSE。部分 macOS 终端会把 SGR 1006 的 ESC[<64/65;…M 原样交给列表页的 Escape 解码;此前该路径只消费左键点击,滚轮因此被静默丢弃。列表页也必须识别 bit 64 的滚轮事件、按坐标确认落在左栏后滚动 ui.top,并保留当前选中会话不变。回归必须同时覆盖 ncurses KEY_MOUSE 与原始 SGR 两条路径。

  • (历史)macOS 旧 curses 的根治:主动请求 SGR 1006:macOS SDK 的 NCURSES_MOUSE_VERSION=1 采用 32 位旧鼠标布局,官方头文件明确没有 Button5 的可用位;Linux 新 ncurses 有 Button5(实测 suzhou ncurses 6.3 协议 v2 下 BUTTON5_PRESSED=0x200000),故同一代码会出现「suzhou 可滚、Mac 不可滚」。_apply_mousemask() 仅在 BUTTON5 不可用的旧平台(按值判断,见下文坑①)于 curses 配置后再发 CSI ?1000h + CSI ?1006h,关闭时对称撤销;之后原始 SGR 序列经 _read_sgr_mouse + _sgr_synth_bstate 合成 bstate,走与 KEY_MOUSE 完全相同_handle_mouse 坐标路由(列表点击/滚轮、pane 滚轮/选词、预览页滚轮缺一不可),鼠标序列绝不当 Escape 文本透传给内层程序。严禁全平台强开 1006:新版 ncurses 自己协商并解码 KEY_MOUSE,强切编码会让终端上报格式变成 ncurses 不认的样子,点击/滚轮全部失灵——v0.16.8 就是这样在 Mac 和 suzhou 同时炸掉的(2026-07-19 用户双机实报,v0.16.9 修复);同一教训的另一半是:改鼠标/终端模式类代码,selftest 的模拟按键不构成验收,必须做一次真实终端冒烟(至少用 tmux pipe-pane 核对进程实际发出的模式序列)。补充三个实测坑(2026-07-18 跨机器排查确认):① Homebrew Python 按系统 ncurses 头文件编译时 BUTTON5_PRESSED 是「存在但等于 0x0」——getattr 的 default 分支救不了它,掩码/判定必须按值判断,否则掩码里下滚位是 0,事件在 ncurses 队列层被整个过滤(曾导致 Mac 只能上滚的直接根因);② 协议 v1 的下滚除了伪装成 BUTTON2_PRESSED,还可能只报一个裸 REPORT_MOUSE_POSITION 位,_is_mouse_wheel_down 在 BUTTON5 不可用的平台上要认这两种形态;③ 跨平台鼠标事件差异只能拿真机事件流定位——设 CORRAL_MOUSE_DEBUG=<文件路径> 环境变量后 _pane_mouse 会把每个 KEY_MOUSE 的 bstate/分类结果追加写入该文件(默认零开销),排查时不用再临时改代码发版。

  • (历史)向下回直播的跨终端兼容规则(2026-07 用户真机确认):不能把 BUTTON2_PRESSED 当作唯一根因或唯一修复;它只是旧版 macOS ncurses 的一个已知伪装形式,触控板和不同终端还会产生无法可靠枚举的 KEY_MOUSE 状态组合。标准 Button5、已知 Button2 都可以优先识别;但只要已经处于回滚(历史偏移大于 0),任何非左键鼠标事件都按“向下回直播”处理,左键事件保留给选词。这一状态优先的降级规则才保证了先上滚、再用鼠标或触控板下滚能够回到直播;不要退回到“把所有鼠标事件转交内层 TUI”或继续猜测某一个位掩码。验收必须在真实终端完成“向上进入历史 → 鼠标/触控板向下 → 回到直播”的完整路径;只确认 SGR 序列能到达内层程序,不构成验收。

  • Textual 版本鼠标处理现状EmbedPane 自己只处理 events.MouseScrollUp/MouseScrollDown 两种滚轮事件(ui/embed_pane.py_on_mouse_scroll_up/_on_mouse_scroll_down_wheel)。托管会话自然方向必须固定为 Up 增加 history_offset(进入更早历史)、Down 减少(回到直播);这个应用层整数不得命名为 scroll_offset,后者是 Textual Widget 的内置二维 Offset,覆盖后会让框架选区坐标执行 Offset + int 崩溃。已结束/未托管会话的静态对话预览用另一套 detail_offset(0=顶部、增大=更靠后),文档式自然方向与 history_offset 符号相反——_wheel 在详情态必须对传入的 local_delta 取反再调 scroll_detail,否则会出现「下滚反而往上看」;2026-07-19 用户实报即此漏取反。直播位置且 pane 内程序申请鼠标上报时,仍转成 SGR press-only:64=上滚、65=下滚,经 embed.send_mouse_sequence 排队发送;已经进入历史或程序未申请鼠标时才更新 history_offset。点击、拖拽由 Textual 自己路由;改动或验收前先确认是在 Textual 事件层还是 embed.py 的 tmux 协议层。

  • 滚轮翻历史必须走应用层滚动,不能用 tmux copy-mode(2026-07 三层根因叠加的教训,用户实报「无法滚动」):① copy-mode 的滚动偏移(scroll_position)只作用于 client 渲染层capture-pane 抓的 pane buffer 永远停在 live 窗口——实测 scroll_position 从 50 涨到 56,capture 内容一个字节都不变,内嵌显示自然纹丝不动;② send -X-N repeat 只对普通键有效(对 copy-mode 命令被静默忽略)、scroll-up 不收行数参数、-X 后多参数按多命令逐个解释(-X -N 3 scroll-up = 三个命令里前两个无效、最后滚 1 行)——每格滚轮实际只滚 1 行,和持续输出会话的新行速度完全抵消;③ copy-mode -e 在视图被新输出追平到底时自动退出,滚上去几秒就被顶回 live。正确做法(现实现):EmbedPane.history_offset 记应用层偏移,Up 增加、Down 减少;变化后经 capture-pane -S -offset -E (pane_h-1-offset) 抓历史窗口渲染——窗口公式经真 tmux 钉死(seq 1 100 会话 -S -6 -E 13 得 76..95,相对 live 82..101 精确上移 6 行);pane_state 顺带取 #{history_size} 作上限;键盘输入归零回直播(C-\ 保留位置)。fake 夹具必须 seq 1 100 预置历史(放在就绪标志输出之前,否则标志行被顶出屏幕),否则 history_size=0 会让滚轮测试假通过。pane 外(左栏)滚轮滚会话列表,其余忽略。

  • (历史)curses 版本手写的内置拖拽选词:左键按下记 emb.sel_anchor、拖动(REPORT_MOUSE_POSITION)实时更新 sel_start/sel_end、抬起按流式区域(跨行连续)复制,文本读 stdscr.instr、复制走 OSC 52、高亮用 stdscr.chgat(A_REVERSE)这套手写实现在 Textual 重写里被整个删除,不是照搬移植,而是换成 Textual 内置的鼠标拖拽文本选择(EmbedPane 不设 ALLOW_SELECT = False)——效果等价(拖拽高亮 + 抬起自动 OSC 52 复制;Ctrl+C 可再复制),实现方式不同:不需要 corral 自己算 sel_anchor/合并跨行区域/管理高亮重绘,Textual 的 Screen.get_selected_text()/get_selection() 直接从 widget 当前渲染的内容里取选中范围。已知取舍m 键关闭鼠标上报后走终端原生框选这条降级路径还在(未受影响);但选区裁剪逻辑(旧版 sel_zone 限制选择不跨过左栏/pane 分界线)没有对应实现——Textual 的选择是按 widget 边界自然裁剪的(EmbedPaneSessionListView 是两个独立 widget,选择不会跨过去),不需要手写裁剪。

  • 会话生命周期:列表焦点下 Esc 退出 corral 不碰任何托管会话(后台 tmux 里继续跑);面板聚焦时 EmbedPane._on_keyEsc 以外的按键转发给 Agent(ctrl+backslash 专门拦截用于焦点回列表,见「输入转发」小节)。curses 版本需要手动等待 300ms 消化完整转义序列以区分方向键/鼠标序列和裸 Esc;Textual 的输入驱动自己完成这层转义序列解析,escape 是稳定的按键事件名,不需要移植这个等待窗口。面板里 agent 进程退出后 tmux 会话消失,capture 线程经 has-session 确认死亡(capture 失败本身不算数,可能只是超时),面板显示占位文案并把焦点弹回列表;c 只关闭分栏布局,不杀会话。

  • 冒烟必须只操作自己新建的会话名:本机其他 corral-*/sc-* 会话通常是真实在跑的 Agent 会话,测试时一律不得 kill-session 或 attach 干扰(同保活节的红线)。

  • 端到端自测脚本(selftest.sh,仓库根,58 项断言;以下记录写于 curses 时代,内置拖拽选词相关断言随该功能移除已不成立,改动或重新核对该脚本时先确认哪些断言仍对应 Textual 版本的真实行为):在独立外层 tmux socket 里跑真实 TUI(隔离 fake HOME + fake claude 夹具——注册 pid 文件、免疫心跳、按行回显、OSC 11 主题探测模拟、按 SID 决定是否申请鼠标),send-keys 驱动按键/粘贴/鼠标序列(\e[<64;x;yM 滚轮、\e[<0;x;yM/m 按下/抬起、\e[<32;x;yM 拖动),capture-pane 抓屏断言;外层 tmux 开 set-clipboard on 后可用 show-buffer 断言内置选词的复制结果。滚轮回归必须断言外层回滚状态:先滚入历史,再发送向下或未知非左键 KEY_MOUSE 事件,确认偏移下降并恢复直播;不得把“内层 fake 收到 SGR 滚轮序列”当作通过条件,因为内嵌模式的滚轮本就不再转发。写断言的坑(全部实踩过):① wait_for 的 grep 必须加 --(模式以 - 开头会被当选项);② 等输出特征别等「命令名」(如等 RESP b' 而非 RESP——命令行回显里就含 RESP 字样会提前命中);③ fake 按行 read,无换行的控制序列要和后续输入凑满一行才落日志,断言控制序列前先补一行普通输入触发;④ tmux attach 到小于终端的窗口时会自画右缘边框竖线 和点阵填充——「竖线消失」不能当全屏判据,要用 corral 自己的列表/提示文案消失;⑤ --no-keepalive 全屏 execvp 的 fake REPL 对 EOF 不退出(busy loop),后续步骤前必须整个外层 session 销毁重建;⑥ fake 的 OSC 11 探测要放在「就绪标志」输出之前,否则探测窗口内到达的按键会被探测进程的 os.read 吃掉;⑦ 屏幕文本读取用 stdscr.instr(不是 inchnstr/innstr——Python curses 只有 instr/inch),其 n 按字节截断,宽字符区域要按格数 ×4 过读再 _fit_cell 按格截回。

会话关注状态(attention.py / attention_signals.py / cursor_observer.py)

  • 产品边界:侧边栏只显示一个小圆点,优先级固定为「等待回答黄 > 执行中绿 > 未读新结果红 > 无」。圆点不参与排序、筛选、计数,不触发声音或系统通知;详情头同步写出文字状态,避免把颜色当唯一信息。标题始终用基础标题样式,不再整行变绿。
  • 黄绿不会高度重叠:黄点只在运行时留下明确的结构化提问、且尚未出现对应结果时出现;普通自然语言问句不算。多数正常执行阶段显示绿点,真正停下来等用户输入时黄点才临时覆盖绿点。不要把 live 直接等同于黄/绿,也不要用问号或关键词猜测待回答。
  • 各运行时证据:Claude Code、Codex CLI、OpenCode、Kimi Code 从本地历史中的明确开始、完成、中止、结构化问题及结果事件推导;Cursor 的结果/问题可以读本地历史,实时开始/结束优先由用户级 hook 提供。历史证据的 observed_at 必须来自真实事件或源文件/数据库时间,禁止用每次扫描的当前时间制造“新变化”。例外:仍活着且历史里有未配对结构化问题时,waiting 与「进程不活 → idle」一样是当前事实,必须把时间推进到已存状态之后,否则一次误判不活或 stop 的更晚时间戳会让黄点永久回不来。
  • Cursor 绿点:只有用户提交(beforeSubmitPrompt)是执行中。afterAgentResponse / stop / sessionEnd 都是本轮结束。禁止把 afterAgentResponse 记成 working——会话进程说完后仍活着,Cursor 历史又不推导 idle,绿点会一直挂着。旧记录若已把该事件写成 working,合并时纠正为 idle。该事件不得清掉未作答的黄点。
  • Cursor 提问落盘:等用户作答时 AskQuestion 往往只在 store.db 的 field-2 protobuf(内层 field 23 + field 57 调用标识),JSON tool-call 要到用户选完才出现。关注信号必须另查这些 protobuf,不能只扫 { JSON;其它工具的 field-2 记录没有 field 23,不能当提问。stop / afterAgentResponse 只表示本轮生成结束,不得清掉未作答的等待;beforeSubmitPromptsessionEnd 可以。配对按 toolCallId 做集合差,不要按 rowid 顺序 pop。已答提问的 protobuf 会一直留着:JSON 结果滚出窗口后,不能只凭 protobuf 还在就亮黄点。提问必须仍是最新动作(后面没有其它工具/正文,且没有落在当前 JSON 窗口之前)才算 waiting。
  • 状态裁决与本地库AttentionStore 默认写 ~/.cache/corral/session-attention.sqlite3,唯一键仍是运行时 + 会话 ID。只保存阶段、活动/问题的不透明令牌、时间、当前裁决和已读基线,不保存标题、提示词、回答或工具正文。临时占位会话转为正式会话时必须按同一托管身份迁移状态;彻底删除会话时同步清理。
  • 首升级基线:首次见到既有历史时把已有结果视为已读,防止升级后所有旧会话批量亮红;当下仍在执行或等待回答的会话照常显示绿/黄。以后只有新的助手结果、完成或中止令牌产生红点。
  • 已读不是“选中过”:红点只有在该会话对应的右侧内容已成功加载并真实可见后立即清除。切换选择、快速掠过、预览失败或应用失焦都不得清;查看不能清掉黄点或绿点。多分屏时所有正在画面上的格子一视同仁,看见了就一起清。观察集合认分屏区当前规格,不要扫换页残留控件。
  • 刷新与性能:关注字段进入列表卡片的轻量刷新签名,但绝不进入排序键。Cursor store.db 默认只在该会话 live 或相关文件签名变化时探测;冷会话的每轮后台刷新不得重复打开数据库。关注状态写入、读取或观察失败一律降级为无状态,不能拖垮首屏或 TUI。
  • Cursor 自动观察:TUI 挂载后在后台幂等检查用户级 ~/.cursor/hooks.json,只管理 corral 自己在 beforeSubmitPromptafterAgentResponsestopsessionEnd 下的条目;保留其他工具条目。写前把原文件备份到 corral 缓存目录,临时文件落盘并同步后再原子替换。JSON 损坏、版本未知、权限不足时停止修改;隐藏 hook 接收入口无论输入损坏还是状态库写失败都静默返回成功,绝不能阻断 Cursor。
  • 公开维护命令corral observer status cursor 只读检查;corral observer install cursor 安装/修复;corral observer uninstall cursor 只移除 corral 管理条目。三者支持 --json 结构化信封;安装和卸载支持 --dry-run 严格预演且不创建配置、备份或目录,status --dry-run 是用法错误。非 TTY 自动输出 JSON;重复安装必须返回无需变更。
  • 与既有状态消歧:关注圆点不得改写标题模块 status_tag、机器接口英文 status 或进程判活 live,也不进入机器接口默认字段。这三套状态服务不同消费方,保持已发布语义不变。

Cursor 扫描(scan/cursor.py / runtime/cursor.py)

  • Cursor Task/subagent 会话必须过滤,不能当作用户发起的顶层 chat 列出。 Cursor 多智能体任务会为每个 subagent 在 ~/.cursor/chats/<ws>/<chatId>/ 落独立目录;meta.jsonisSubagent: true(即使未来版本补了 title/cwd/prompt_history 也必须跳过)。次级信号在 store.dbmetasubagentInfo.parentAgentId / rootParentAgentId,列表扫描阶段不读 store.db,只认 meta.isSubagent判活例外(2026-08-30):子代理仍在跑、父进程已空闲或不在时,父会话必须保持进行中。--resume 或打开的 store.db 若指向被过滤的子代理 chat,按需读该 chat 的 subagentInfo,把 live/pid 记到顶层父会话;父进程仍在则保留父 pid,禁止被子代理 pid 覆盖。不要把子代理重新列入侧栏。回归:test_live_flags_resume_subagent_marks_parent_live 一组。

  • 历史只扫 CLI:~/.cursor/chats/<workspace>/<chatId>/(不扫 IDE agent-transcripts)。

  • 列表轻扫:meta.json + prompt_history.json(最新在前);path 优先指向 store.db

  • 完整对话:load_conversation 只读 store.db JSON blobs,提取 <user_query> 与 assistant 文本。

  • 必须读 WAL,禁止 immutable=1(2026-08 真机:预览/小窗漏最新消息):Cursor store.db 长期 WAL,最新轮次常停在 store.db-wal、主库尚未 checkpoint。file:…?mode=ro&immutable=1 会跳过 WAL,表现为右栏对话与 HUD 提示词小窗都缺尾巴;主库几乎为空时甚至 no such table: blobs 后只剩 prompt_history 用户侧回退。打开用 mode=ro(可带短 timeout)。对话内存/落盘缓存签名必须把 path-wal 算进去(store._conversation_version / cache.history_signature),否则主库 mtime 不动时会一直命中缺尾的旧缓存。关注信号冷读同理:存在 -wal 时不得 immutable。回归:CursorScanTests.test_load_conversation_reads_uncheckpointed_wal_tailtest_conversation_cache_invalidates_when_sqlite_wal_changes

  • 运行时 id=cursor,可执行文件=agentauto_approve_args=("--force",);恢复 agent --force --resume <id>

  • 同 cwd 多 agent 判活(2026-07 真机实报后修,同日再修串台;2026-07-23 再修 MainThread;2026-08-30 再修子代理改绑父会话):跨助手接力 / 空白新建会在项目目录起无 --resumeagent,同时旧会话可能仍以 --resume <chatId> 跑着。第一版用 live_pids_by_process_name("agent") 按 cwd 只留一个 pid,新接续标题会挂上旧保活画面。第二版改走 live_processes 保留全部进程,无 resume 时按「cwd → mtime 最新未标记会话」兜底——仍会把空壳欢迎页进程错绑到同目录更早的真实历史(真机:侧边栏「我想加个顶栏」、右栏却是空白 Cursor 欢迎页;lsof 显示该进程实际打开的是另一条 chat 的 store.db)。现绑定只认正向证据,优先级:① --resume 的原托管进程经已打开的 store.db / 完整 CORRAL_SESSION_ID;② 命令行 --resume <chatId>(及仍未绑上的 resume 进程的 open-store/env)。命中的 chat 若是被过滤的 Task/subagent,改绑到 subagentInfo.rootParentAgentId / parentAgentId 的父会话,父进程不在也须保持进行中。禁止再按 cwd/mtime 猜测。另:新版 Cursor agent 会把 /proc/<pid>/comm 改成 MainThreadpgrep -x agent 恒为空——live_processes("agent") 必须按 cmdline(argv0=agentcursor-agent/.../index.js,排除 worker-server)兜底,否则 live 全灭、占位卡无法退役,侧边栏出现「临时 8 位卡 + 真实 UUID 卡」双份,点真实卡还会再起一份 --resume。回归:CursorScanTests.test_live_flags_bind_resume_and_open_store_separately_in_same_cwdtest_live_flags_do_not_bind_blank_agent_to_older_cwd_historytest_live_flags_bind_via_corral_session_envtest_live_flags_prefer_blank_host_over_secondary_resumetest_live_processes_agent_finds_mainthread_renamed_processtest_live_flags_resume_subagent_marks_parent_livetest_live_flags_open_subagent_store_marks_parent_livetest_live_flags_nested_subagent_binds_root_parenttest_live_flags_parent_pid_wins_over_subagent_pidtest_live_flags_subagent_without_parent_store_does_not_guess。OpenCode/Kimi 仍用 cwd→单 pid 的保守策略(它们没有稳定的 resume 参数可解析)。

直启子命令(corral claude / corral codex / corral opencode / corral kimi

  • 定位main() 里在 agent_api 分发分支之后、TUI 的 argparse 之前再加一个前置分支——sys.argv[1](跳过可选的前置 --no-keepalive)命中 registry.ids 就整体转发给 _dispatch_direct_launch,不进入下面的 TUI/--json 参数体系。这个顺序刻意和 agent_api 的分发方式对称:两者都是"整个命令行属于另一套子系统,不该被 TUI 的 argparse 解析"。分发后默认进 TUI 侧边栏模式托管新会话(见「会话保活」节对应条目),非真实终端 / --no-keepalive / 内嵌不可用时才 execvp 全屏接管。
  • 两种形态
    1. 项目快捷启动 corral <runtime> <project>:第二个位置参数不以 - 开头时,当作项目名(大小写无关模糊匹配)。命中后在该目录 build_new_session_plan(cwd) 新建空白会话(不 resume)。0 命中报错退出;多命中时交互终端编号选择,非 TTY 直接失败并列出候选。项目名后再跟其它参数一律报错(避免 corral claude subswap -p x 语义含糊)——需要透传时用 corral claude --…
    2. 透传 corral <runtime> / corral <runtime> --flag …:无额外参数,或首个用户参数以 - 开头 → build_passthrough_plan(只垫 auto_approve_args)。
  • 项目发现projects.py,与 TUI「+ 新建」的 store.projects() / pick_project 共用):合并会话历史里的有效 cwd ∪ 本机 git 根扫描。默认扫 $HOME(深度 4,命中 .git 后不嵌套);CORRAL_PROJECT_ROOTS(逗号分隔)覆盖扫描根,设为空字符串则跳过文件系统扫描;CORRAL_PROJECT_DEPTH / CORRAL_PROJECT_EXCLUDE 可选。必须跟随目录软链接_scan_one_root 自己写 DFS(不用 os.walk,它 followlinks=False 会整棵漏掉软链子树,followlinks=True 又会成环),逐层 realpath 去重防环,收录的键统一是真实路径,这样与会话历史里的 cwd 能对上、不会同一个项目出现两条。真实踩坑:suzhou 上 ~/Codes -> /Users/geraltgraham/Codes,旧实现从 $HOME 扫出 0 个项目,corral kimi alpha 只能靠"有过会话记录的目录"兜底,从没开过会话的项目一律「未找到匹配项目」。回归:test_scan_follows_symlinked_subdir / test_scan_symlink_cycle_terminates硬排除目录名(按目录名剪枝,不依赖用户配置):.stversions.stfolder(Syncthing 版本快照里常残留 .git,扫进去会冒出幽灵项目)、node_modules.cacheLibrary、以及其它常见点目录噪音。回归:tests/test_projects.pytest_scan_skips_stversions_syncthing_snapshots
  • 项目名匹配分两档match_projects):子串档(名字 / 标签 / 路径包含查询串,rank 0–3)与子序列档(_fuzzy_match 打散字符,rank 4–6)。只要有任何子串命中,就整体丢掉子序列命中——否则 alpha 会连 LLMPlatform/archive/java-platform(j-a-va-p-l-atform… 顺序恰好凑得出)一起列进候选,真正想要的 AlphaForge/* 被淹在 10 条里。一个子串都没命中时才退回子序列,sbswp → SubSwap 这种缩写输入仍然可用。回归:test_substring_hits_suppress_subsequence_noise / test_subsequence_still_works_without_substring_hit
  • 模型与推理强度由各助手的全局设置决定:corral 在恢复、接力、空白新建和直启透传中都不得静默注入模型或推理强度。这样用户在助手侧指定的默认值会稳定覆盖所有启动路径;仅用户显式传入的参数可以改变单次会话。直启仍只补齐自动批准参数。
  • 危险参数改成运行时类属性 auto_approve_argsruntime/base.py 声明、runtime/claude.py/runtime/codex.py 各赋值一次),原本在每个适配器的 build_resume_plan/build_continue_plan/build_new_plan/build_new_session_plan 四处各写一遍字面量字符串,现在四处和直启共用同一份声明。新增运行时想接入直启子命令,只需要declare 这个类属性(不声明则默认空元组,直启不会额外加任何参数)。
  • 放行参数的位置由适配器自己说了算BaseRuntime.compose_passthrough_argv):注册表只调用这个方法,不再自己拼 argv。默认实现是"垫在最前、用户已带过就不重复";OpenCodeRuntime 覆写了它,因为 --auto 既只属于部分子命令、又对位置敏感(见「OpenCode 扫描」节最后一条)。新增运行时若也存在"放行参数只在特定子命令下有效"或"位置敏感"的情况,照这个方式覆写,不要为了凑统一模式硬塞进注册表、把裸命令打坏。
  • 用户在透传参数里已经带了该运行时的危险参数时不重复添加build_passthrough_planarg not in user_args 过滤),这样 corral claude --dangerously-skip-permissions --resume xxx 这类用户自己拼好完整参数的调用不会看到参数被加两遍。
  • cwd 语义:透传形态下 cwd 恒为 None(就地拉起);项目快捷启动形态下 cwd 为匹配到的项目绝对路径(与 TUI 新建空白会话一致)。不要把两种形态的 usable_cwd 用法混掉。
  • _dispatch_direct_launch 捕获 execute_launch / 项目解析抛出的错误,打印信息并 sys.exit(1),不让用户看到裸 Python 堆栈。
  • 入口探测用 registry.launch_tokens,展开用 registry.resolve_id():前者是"运行时 id + 可执行文件名 + executable_aliases"的集合,main() 只拿它做一次成员判断;命中后 _dispatch_direct_launch 再把第一个词展开成真正的 id,后续逻辑一律只认 id。入口探测必须保持"集合包含"语义,不能改成"某个函数返回真值即命中"——cli.main 的既有测试用 MagicMock/SimpleNamespace 替换整个 registry,任何返回真值的探测函数都会让 --version 这类普通命令被误判成直启,进而真的 execvp 出去把测试进程替换掉(实测现象:pytest 整体静默退出、tmux 打印 server exited unexpectedly,没有任何失败用例信息,极难定位)。回归:tests/test_cli_options.pytests/test_shim.py::DirectLaunchAliasTests
  • 别名只在 executable_aliases 一处声明:Cursor 的安装脚本同时提供 agent(官方现行主名,即 CursorRuntime.executable)和 cursor-agent(兼容名,登记为别名),所以 corral cursor / corral agent / corral cursor-agent 三种写法等价。新增运行时若也有多个入口名,加进这个类属性即可,不要在 cli.py 里写死映射表。

命令拦截(shim.py)

  • 形态是交互式 shell 函数,不是 PATH shim 目录,理由与全部放行判据写在 shim.py 的模块 docstring 里,改之前先读那段。核心一句:拦截只该作用于"用户手敲",脚本 / CI / 编辑器插件 / 别的 Agent 拉起的子进程一个都不该被托管。

  • corral 自己会无头调用 claude -p / codex exec 生成标题titlegen.py)。放行判据里"非真实终端"和"参数命中无头/管理类词"两条都不能删,删任何一条都会让标题生成被包进 tmux 托管、静默失效并堆积进程。

  • 防递归三重保险:shell 函数不被子进程继承(bash 不 export -f、zsh 不导出)、走 corral 那一支带 CORRAL_SHIM_ACTIVE=1、托管会话里已注入的 CORRAL_RUNTIME(及旧名 SC_RUNTIME)触发放行。三条互相独立,不要因为"看起来重复"删掉任何一条。不要把用户自己的 TMUX/STY 当成放行条件:corral 的保活用独立 socket(tmux -L corral-keepalive),和用户日常开的复用器不是一层;把「在 tmux 里」一律放行,等于日常在 tmux 里敲 claude/agent 永远进不了托管。回归:test_own_tmux_still_hosts_interactive_commandstest_legacy_sc_runtime_guard_passes_through

  • 失败方向必须是"没托管"而不是"命令坏了":找不到 corral、非 TTY、脚本文件缺失(配置里的 source-f 判断)全部退回 command <cmd>。用户的 claude 因为装了 corral 而不可用,是这个功能唯一不可接受的失败。

  • 首次使用必须无感自动启用:安装脚本完成安装后立即尝试启用;交互式启动 corral 时也必须幂等补齐,避免用户装完却以为裸 codex 已被托管。仅真实交互终端可触发这次补齐;版本查询、Agent 只读接口、管道/脚本调用和 corral shim ... 管理命令绝不隐式写配置。没有可拦截运行时、未知 shell 或配置不可写时静默降级,不得阻断 corral;用户仍可用 corral shim install 主动修复。写入前备份原文件,配置里只放一行 source,函数正文在 ~/.cache/corral/shim/ 的生成脚本里——升级只需重写脚本,不必反复动用户配置。

  • 状态判定按整行匹配函数定义_shimmed_commands):agentcursor-agent 的后缀,用子串判断会把"只拦了 cursor-agent"误报成"agent 也拦了"。回归:test_agent_is_not_reported_as_shimmed_by_cursor_agent_suffix

  • agent 认出是 Cursor CLI 才拦:官方现行主命令就是 agentcursor-agent 是兼容名)。名字太通用,default_on=False 防止误伤其它同名工具(asdf 社区有 shim 误伤 clear 的先例);但 PATH 上的 agent 能从路径或脚本头看出是官方 Cursor 安装或 cursor-mode-model 包装时必须自动选中,否则用户天天敲的入口根本进不了托管。认不出时仍可用 --include agent 强制。探测只读文件、不执行该命令。回归:test_agent_is_auto_selected_when_binary_is_official_cursor_clitest_generic_agent_binary_is_not_auto_selected

  • 管理类子命令只认第一个位置参数claude please update the docs 不得因为提示词里有 update 就放行;无头旗标(-p / --print 等)仍在任意位置命中即放行。Cursor 的 about / models / whoami / help / --list-models 等管理入口必须在 CURSOR_PASSTHROUGH 里,拦了 agent 之后这些调用不能被包进托管会话。

  • TARGETS 与默认注册表必须同步:新增运行时要同时在这里登记可拦截命令名与放行子命令,tests/test_shim.py::ShimTargetTableTests 会断言两边不漂移。

  • 手敲 pi 会被改写成 corral pi 进托管新建,每次是一个新的 8 位 ident,因此每次启动都会看到 Pi 打出的 Warning: No project session found with id '…'。这是预期且无害的,定性与禁止的误修路径见「Pi 扫描与启动」。

  • 验证手法:tests/test_shim.py 里的 ShimBehaviourTests 用真实 bash + 伪终端(pty)跑生成的脚本,逐条验证"该托管的托管了、该放行的放行了"。管道场景验证不了正向拦截[ -t 1 ] 不成立会一律放行),正向用例必须走 pty。zsh / fish 的语法与行为在容器里用真实 shell 验证过(本机没装这两个 shell 时对应用例自动跳过)。

  • list/search/resolve_refshow/context/plan continue 共用)在扫描多个运行时时统一走 _scan_runtimes 辅助函数:ThreadPoolExecutor 并发扫描 + 单运行时异常隔离,与 runtime/registry.pyscan_all() 同语义但独立实现(机器接口按需只扫描 --runtime 指定的子集,不复用 TUI 那份)。之前是逐个运行时串行 scan_sessions(),运行时数量越多、corral list/search 不带 --runtime 时延迟越接近各运行时耗时之和;改并发后接近最慢那个运行时的耗时。新增调用点需要扫描多个运行时时复用这个函数,不要退回字典推导式的串行写法。

  • list/search/show/context/plan continue/describe 的 JSON envelope 结构({ok, data, error, meta})、 退出码分配(0/1/2/3/5)和已发布字段名是对外契约,一旦发布过版本就按“只加不改不删”演进; 确需破坏性变更时同步提升 agent_api.AGENT_API_VERSION 并在 docs/SKILL.md 标注。

  • 新增子命令或参数只在 agent_api.pyCOMMANDS 列表里加一份定义——corral describe 的输出、 argparse 的参数解析共用同一份数据,不要为 describe 另写一套文案,否则会和真实行为漂移。

  • --compact 精简字段集是「给人看」的默认值,不是「给机器控制逻辑」的默认值list/show 单独传 --compact 时只返回 DEFAULT_LIST_FIELDS/DEFAULT_SHOW_FIELDS,两者都不含 cwd/pid。 这曾在 OpenConductor 接入时造成真实故障:internal/agentcontrol.SCClient 只传了 --compact, 拿到的每条会话 cwd/pid 恒为空——不是报错,是静默拿到零值,导致停止动作因缺 pid 直接判定 「进程无效」、项目归属判断因缺 cwd 退化为「无归属,仅机器主人可见」,两者都不会在日志里报错, 只会表现为功能悄悄不工作。show 因此在这次修复中补上了 --fields(此前只有 list/search 支持):任何需要 cwd/pid 等非默认字段的调用方,必须显式 --fields id,runtime,cwd,pid,... 指名,--compact 只负责 JSON 排版(不缩进),不能假设它顺带给出全部字段。

  • title 字段只读 titles.load_cache(),不得在 agent_api.py 里触发 refresh_titles;机器接口 不消耗 Claude 额度是硬约束,触发生成的入口只能是 _spawn_title_daemon 拉起的后台进程。

  • 续接计划仍是只读数据corral plan continue <runtime:id> --instruction <文本> 只验证目标会话、 读取 runtime 能力并返回统一 envelope 中的会话事实、能力列表与执行计划;它不得启动进程、发送信号、 写入历史或改变终端。真正执行计划的是调用方(例如 OpenConductor),不是 corral。

  • 执行计划禁止 Shell 拼接:计划必须以 argv 数组与 cwd 表达,调用方使用无 Shell 的进程启动 API 逐项传入参数;不要返回或消费可交给 sh -ceval 等解释的命令字符串。这样含空格、引号或 用户需求文本的参数不会被二次解释,也不会把只读计划接口变成命令注入入口。

  • 第三方 runtime 的续接扩展点:新增 runtime 时,在 adapter 实现 build_continue_plan,由它把已 扫描到的原生会话转换为统一的 argv/cwd 计划;不在 agent_api.py 添加按 runtime 分支。不能原生 续接的 runtime 应明确返回不可续接能力,而不是伪造计划;实时下发指令同样不属于此扩展点。

  • status 是给程序判断用的英文枚举(STATUS_LABELS),status_tag 是给人看的中文 + emoji; 新增状态时两边要同步更新,不能只加一边。

  • list/search--limit 是每个运行时的扫描深度,--top 才是最终结果数量上限;不要为了省事 把 --limit 改回“扫描多少就返回多少”的混合语义。Agent 调用通常同时传 --limit--top: 前者控制找多深,后者控制 token。

  • list/search 的返回行必须保留 resumable/resume_commandsearch 还必须保留 scorematched_viamatched_fields,排序按相关性分数优先、更新时间次之。matched_via 已发布过 quick/deep 语义,不能改成数组;字段级命中信息放在 matched_fields。计算这些字段不能读取完整 会话文件,避免破坏首屏 <1s 和 Agent 查询的低 token/低延迟目标。

  • --compact 同时表示无缩进 JSON 和默认精简字段集;--fields 只能进一步裁剪/覆盖字段,不要让 compact 模式输出比普通模式更大。

  • show --full 的大结果优先配合 --out 落盘,stdout 只返回路径、字节数和消息数量摘要;完整 JSON envelope 写到目标文件。这个写文件行为只允许发生在用户显式传 --out 时,不能变成默认副作用。

  • cli.py 的非 TTY 自动降级(sys.stdin.isatty() and sys.stdout.isatty())写在 main() 顶部,早于旧版 --json/--limit 的 legacy parser;list/search/show/context/describe 的子命令分发更早一层,在 bootstrap.pymain() 里(那层刻意不 import Textual/扫描器,见性能知识库「性能架构」); 改 main() 时不要把两条路径的参数解析合并到同一个 argparse.ArgumentParser,legacy 路径的报错 仍是给人看的文本,机器接口路径的报错必须是 JSON envelope,混用会破坏其中一边的调用方假设。

  • AgentApiTests 里给 store_true 参数写测试的坑_registry/各测试方法构造 args 大多是裸 mock.Mock(...),只显式传了用到的关键字;访问没传的属性时 Mock 会自动生成一个新的、truthy 的 Mock 实例,不会抛 AttributeError,也不会落回 getattr(args, name, default)default。 所以 cmd_list/cmd_search 里判断 --live 用的是 getattr(args, "live", None) is True,不是 常见的 truthy 写法——用 is True 才能让"老测试没传 live 参数"正确落到"未开启",而不是被 自动生成的 Mock 误判成"已开启,过滤到只剩 live 会话"。新增任何 store_true 参数、且要在 cmd_* 里按它做条件分支时,同样用 is True 这个模式;纯读值转发(如 compact/out)目前 所有测试都显式传了值,暂时安全,但新写测试时也建议养成显式传 live=False 等布尔关键字的习惯, 别依赖 Mock 的默认行为。

  • live/pid 从扫描层到接口的传递scan.claude._live_session_ids()/scan.codex._live_session_ids() 返回 {会话ID: pid} 字典(不是纯 set),scan_sessionsinfo["live"] = info["id"] in live_ids 之后紧跟 info["pid"] = live_ids.get(info["id"]);两个运行时判活时手上本来就有 pid(Claude 是 ~/.claude/sessions/{pid}.json 的文件名,Codex 是 pgrep -x codex 循环里的 pid),顺手带出, 不需要额外系统调用。agent_api.session_payload 直接透传这两个字段,不做二次判活。

  • 面向管家 Agent 的可见性 vs 只读边界:为了让管家 Agent 能回答"现在哪个 CodingAgent 在跑", list/search/context 暴露了 live(进程真实存活)、pid(配合 live 使用)。这只是 暴露可见性,不是新增执行能力——agent_api.py 仍然是纯只读接口,不提供"向运行中进程发送 指令/接管会话"的命令;管家拿到 pid 之后想做什么是调用方自己的事,corral 不代劳,也不应该代劳 (只读边界详见文件头注释和 AGENTS.md)。

  • list/search 默认带摘要DEFAULT_LIST_FIELDS/DEFAULT_SEARCH_FIELDS 默认含 last_user/ last_agentsession_payload 里用 _trim() 硬截断到 _SUMMARY_TRIM_LEN,约 120 字),让管家 一眼看懂"这条会话在聊什么",不必为每条候选都多一次 corral show 往返。这两个字段本来就是扫描阶段 已经提取好的 last_user_msg/last_agent_msgsearch 的 haystack 早就在用),只是之前没有 暴露给 Agent 接口;pid 因为多数场景是 null、只在 live=true 时有值,没有进 --compact 的 精简默认集,避免精简模式反而字段膨胀。

会话管理与检索的 Agent 可用性设计取舍

给管家 Agent(OpenConductor)设计 corral 的可用性时,明确讨论过并否决了两个方向,记录下来避免以后 重复纠结:

  • 不做"下发指令给运行中会话"的执行命令:管家想"指挥某个正在运行的 CodingAgent",最直接的实现 是 corral 直接往目标进程注入输入或调用其 API。这会打破"agent_api.py 只读、无副作用"的硬架构约束, 让 corral 从数据接口变成执行器,责任边界和风险都会显著上升。最终选择只增可见性(live/pid), 接管逻辑留给管家自己基于这些数据去实现,corral 不跨这条线。
  • 检索不引入语义/向量搜索:现有关键词子串匹配(标题权重最高,其次首尾消息、目录)已经够用, 上语义搜索要引入嵌入依赖、离线索引维护和额外算力/额度成本,与 corral"轻量、依赖极简、离线可用"的 定位冲突,暂不做(2026-07 界面层引入 textual 作为唯一第三方依赖后,这条结论权衡的份量不变——嵌入 模型的依赖体积和离线维护成本比 textual 高一个数量级,不构成"反正已经不是零依赖了就无所谓"的理由)。

可观测性(怎么用)

corral 是本地 TUI,不是常驻服务:禁止 Prometheus / 远程遥测。诊断靠本地文件与只读子命令。

用途入口
结构化事件(scan/rebuild/host/慢抓帧/截图/error)~/.cache/corral/events.log(一行一条 JSON)
后台异常 traceback~/.cache/corral/embed-error.log
真机当前屏截图TUI 内 F12~/.cache/corral/screenshots/tui-*.svg
README/改动夹具截图python3 docs/screenshots/capture.py
端到端冒烟bash selftest.sh
一键只读诊断corral diagnose(JSON:路径、tmux、配色自检)
细日志CORRAL_DEBUG=1CORRAL_LOG=debug(额外 debug 事件)

实现模块:observe.pyevent / debug / timed / log_exception / save_tui_screenshot)。corral._log_embed_error 转调 observe.log_exception(events 一条 + embed-error 栈)。

事件名约定:scan_alllist_rebuildhost_sessioncapture_slow(≥100ms)、screenshoterror。默认不写对话正文;敏感字段名会被改写为 <redacted>

README/夹具截图用 python3 docs/screenshots/capture.py(会清 NO_COLOR、去 Rich 假窗口铬)。配色验收亦可对照真机或 render_line segment。

客户端自动更新(updater.py / ui/update_toast.py)

  • 业务逻辑集中在 src/corral/updater.py,与 UI/CLI 解耦、可独立测试:版本比较(current_version/is_newer)、安装渠道判定(detect_channel)、最新版查询(fetch_latest:打 https://api.github.com/repos/x0c/corral/releases/latest,取 tag_name 去掉前导 v,3 秒超时,任何异常一律返回 None,不得抛出拖垮调用方)、安装源解析(install_spec/release_asset_url)、就地升级命令(update_command)。
  • 渠道判定顺序不可乱:brew → pipx → pip → dev。 brew 看路径含 /Cellar//homebrew/linuxbrewpipx 看解释器 venv 根下有 pipx_metadata.json(与 PIPX_HOME 具体位置无关:macOS 在 ~/Library/Application Support/pipx,Linux 在 ~/.local/share/pipx),路径含 pipx/venvs 作为兜底;pip 看是否落在 site.getusersitepackages()/site.getsitepackages() 或路径含 site-packages;其余是 dev(源码检出/editable,一律不可自动升级、也不弹窗)。
  • 踩坑(2026-07-30 真机):pipx 装的 corral 点「更新」必失败。 早期 detect_channel 没有 pipx 分支,pipx 安装因为路径含 site-packages 被判成 pip,于是执行 sys.executable -m pip install --upgrade …——而 pipx 创建的 venv 默认不装 pip,命令必然以 No module named pip 收场。修法是给 pipx 单开渠道,命令走 pipx install --force <安装源>pipx upgrade 只会按原始 spec 重装,本机从本地目录装的场景升不到新版;--force 是覆盖已装同名应用的必需参数,pipx 会从包名/包文件名推断应用名)。找不到 pipx 可执行文件时 run_update 直接返回可读原因,不要让子进程报一句看不懂的话。
  • 安装源必须优先用 Release 里的预编译包release_asset_url,与 install.sh 同一套按系统/架构匹配的命名规则:macOS 取 universal2,Linux 按 glibc/musl 与架构取 manylinux_2_17_*/musllinux_1_2_*)。corral 带 Rust 扩展,退回源码(git+https://…@v<tag>)要求本机有完整 Rust 工具链,绝大多数用户没有——只有查不到匹配预编译包时才走这条兜底。pip 渠道是否带 --user 仍取决于当前安装路径是否在用户 site-packages 下。
  • 忽略状态持久化在 ~/.cache/corral/update.json{"dismissed_version", "dismissed_date"}),写法复用 titles.py 的原子写惯用法(.tmp.{pid} + os.replace,避免并发读到半截 JSON)。should_prompt(latest) 语义:latest 严格新于当前版本,且不满足"今天已经忽略过这个版本"——同一天忽略后不再弹,换一天或出了更新的版本会恢复提示。
  • TUI 侧:ui/main_screen.pyMainScreen.on_mount 起一个 @work(thread=True)_check_for_update,只在 is_updatable(channel) 为真时才查网络,dev 渠道直接跳过、完全不发请求(源码检出/开发安装因此永远不会被打扰)。有满足条件的新版本时 call_from_threadui/update_toast.pyUpdateToast 切到 available 状态。点击更新后禁止在 Textual worker 里原地装包:Homebrew / pipx / pip 都可能删除当前解释器或 site-packages,旧界面下一帧再惰性导入 markdown_itrich.traceback 等尚未加载的模块时就会以 ModuleNotFoundError 崩溃(2026-08-14 真机事故,旧进程为 v0.24.99)。点击必须立即 app.exit(result=RestartRequest(latest, channel)),让主循环完全结束后再动安装目录;升级失败原因改在恢复后的普通终端里完整显示。
  • 升级与重启机制:RestartRequestupdater.py 里的冻结数据类,携带目标版本与安装渠道,与既有的 LaunchRequest/NewSessionRequest/None 并列作为 run_app() 的第四种返回值语义。cli.pymain()_dispatch_direct_launch()run_app() 返回后调用 _finish_self_update():先执行 updater.run_update(),失败则打印完整原因并非零退出,成功则调用 _restart_process()。重启优先 os.execv(PATH 上的新 corral, …),因为 Homebrew 已把旧 Cellar 解释器删除;只有找不到命令入口时才退回当前解释器的 -m corral。tmux 保活会话与本进程生命周期无关,重启不影响已托管会话。
  • corral update 终端子命令(cli.py main() 顶层拦截 sys.argv[1:2] == ["update"],转发给 updater.cli_update())用于不开 TUI 时手动触发升级;故意不放进 agent_api.py——那里的架构约束是只读、无副作用命令,update 有真实写盘/装包副作用,不符合这条边界。
  • 真机调试踩坑(务必记住)UpdateToastContainer 子类)最初把状态刷新方法命名为 _render,与 Textual Widget 基类自身用来计算可绘制内容的内部方法 _render() 同名,被静默覆盖后返回 None;框架在需要自绘时(如 dock: bottom + align: right bottom 的浮层容器里,子节点未占满的空白区域)调用 self._render() 拿到 None 而不是真正的 Visual,导致 Visual.to_strips 内部对 None.render_strips 崩溃(AttributeError: 'NoneType' object has no attribute 'render_strips')。排查耗时很长是因为崩溃现象(align+dock+首次由 display:none 变为可见)看起来像是这些 CSS 属性组合的问题,实际和它们毫无关系——真正诱因是方法名冲突。教训:自定义 Widget 子类的私有辅助方法一律避免使用 _render/render 或任何与 Textual 基类同名的下划线前缀方法;已在 update_toast.py 顶部注释和方法命名(_sync_display)里固化这条教训,之后再新增浮层/自定义 Widget 时先检查方法名是否与 textual.widget.Widget 的现有方法(render/_render/get_content_width 等)冲突。
  • 浮层定位手法:外层 UpdateToast(Container)layer: overlay; dock: bottom; align: right bottom; 把自己锚到屏幕右下角(镜像 Textual 内置 Toast/ToastRack 的定位手法:单个 leaf widget 自身 docked 时无法把自己右对齐——align 只作用于容器的子节点,所以外层必须是容器);不作为 #list-pane 子节点挂载,不受"侧边栏末行间隔"硬约定牵连;也不必显式声明 CSS layers: 列表,未声明的 layer 名称 Textual 会自动登记。"忽略"命中区(_ToastClose)的可见性完全靠 CSS 类选择器驱动(UpdateToast.-closable #toast-close { display: block; }),不在 Python 侧用 .display = bool 直接改子节点样式,避免任何"父容器首次可见的同一拍内又改子节点显示状态"的时序脆弱点。
  • 浮层在任何非 updating 状态都必须能关掉。 早期只有 available 露出关闭区,结果升级失败后浮层关不掉、一直横在界面底部(2026-07-30 真机事故)。现在 available/failed/done 都带关闭区,只有 updating 不给关(子进程在跑,关掉会让用户以为取消了):available/failed 点关闭记为「今天不再提醒这个版本」(升不上去还每次开都弹才是骚扰,次日恢复提醒);done 点关闭只隐藏(已经装好,下次启动版本已最新、本就不会再弹)。新增浮层状态时先回答"这个状态用户想关的时候关得掉吗"。
  • 测试覆盖:tests/test_updater.py(版本比较、渠道判定、fetch_latest mock、忽略状态、cli_update 三条主路径)、tests/test_update_toast.py(Pilot 驱动的状态机与点击行为,纯 widget 级别)、tests/test_main_screen_update.py(Pilot 驱动的 MainScreen 接线:点击更新只退出界面并返回稳定请求,断言此时尚未调用安装器)、tests/test_cli_restart.py(断言退出界面后才升级、升级成功后才 re-exec、失败不重启)。开发树里 detect_channel() 恒为 "dev",因此 MainScreen/CLI 层的"有新版本"测试必须 mock updater 对外函数,不能依赖真实网络或真实渠道判定。
  • 跑这些单测必须确认加载的是仓库源码:系统 python3 常会 import 到用户 site-packages 里的旧安装副本,改完源码测试却仍绿/仍红都可能是假象。要么 bash scripts/dev-install.sh,要么 PYTHONPATH=src python3 -m unittest …。渠道判定改动还要额外在目标解释器下实测一次(如 pipx 的 …/pipx/venvs/corral/bin/python),单测里的 mock 路径覆盖不到真实安装形态。

开源发布

  • GitHub 公开仓库是 https://github.com/x0c/corral,本地远端名为 github;原 origin 仍指向内部 Forgejo,用于同步备份。
  • 项目历史版本线已经到 v0.2.x,新增公开发布版本必须沿现有标签递增,不能从 0.1.0 重新开始。
  • 打包元数据只维护 pyproject.toml 与 Rust Cargo.toml 的必要版本同步;构建后端是 Maturin,原生模块使用 Python 3.10 稳定 ABI。不要再引入 setup.cfg 双源。控制台入口走轻量 corral.bootstrap:main,避免版本和只读子命令加载完整界面。
  • .github/workflows/test.yml 必须先执行 python -m pip install . 再编译和跑测试,不能假设 GitHub Runner 预装运行依赖。项目从零依赖迁移到 Textual 后曾因 CI 只 checkout 源码就直接跑 unittest,导致 rich/textual 导入失败、Python 3.10–3.13 全矩阵同时报红;新增或调整依赖时要把“全新环境能按项目元数据安装”作为 CI 的第一道验证。
  • 发布前至少构建一次 wheel 和源码包,确认 wheel 带 cp310-abi3 与当前平台标签,并用临时目录安装后检查轻量入口与原生扩展可用。正式 Release 必须含 macOS 通用轮子、Linux glibc/musl 的 x86_64 与 aarch64 轮子及 SHA256SUMS;细则见 PERFORMANCE_KNOWLEDGE_BASE.md
  • 开源前隐私扫描要覆盖准备提交的文件和完整 Git 历史补丁内容;本机 .git/config 里的内部远端不进入仓库内容,但真实文件、历史提交、Release 说明和 README 不能包含密钥、个人路径、内网地址或占位符。
  • GitHub Release 发布后检查 Actions、Release、topics 和仓库可见性;当前仓库 topics 为 claude-codecodex-cliterminaltuisession-managerai-coding-agent

一键安装渠道

  • Homebrew 配方在独立仓库 x0c/homebrew-tapFormula/corral.rb,由本仓 scripts/homebrew_formula.py 整体生成(两个调用方:scripts/publish-release.shrelease.yml 的 bump 任务),不手改 tap 里的文件;Aliases/session-continue 软链到 corral,兼容改名前的 brew install/upgrade x0c/tap/session-continue安装策略是「预编译 wheel 优先、源码兜底」(2026-08-23 起):macOS 槽位直装 Release 里的 universal2 wheel(双架构、所有 macOS 版本通用,不拉 Rust 工具链),Linux 按架构直装 manylinux wheel;某平台缺 wheel 时该平台才指源码归档并声明 maturin/rust 构建依赖(构建依赖包在 on_macos/on_linux 里,不给有 wheel 的平台平白拉 Rust)。旧配方永远源码编译、每次 brew install 都要下几百 MB Rust 工具链,是用户实测太慢的直接根因。机制依据(改生成器前先复核 Homebrew 是否仍如此):brew 下载 .whl 走 Uncompressed 解包策略(按扩展名找不到策略、又不是目录,文件原样落在 buildpath),Dir["*.whl"] 可直接 venv.pip_install;且 std_pip_args 的 --no-binary=:all: 不阻止安装 wheel 文件路径。纯 Python 运行时依赖(textual 连带 rich/markdown-it-py/mdurl/pygments 等传递依赖)的 resource 块维护在生成器脚本里,依赖升级时同步改(可用 brew update-python-resources/homebrew-pypi-poet 类工具生成后贴进去),并同步 depends_on "tmux"
  • 新打 v* 标签并推送后,.github/workflows/release.yml 的 bump 任务会按该 tag 的 Release 附件重新生成配方(所有平台 wheel 齐全时全走直装)并提交到 x0c/homebrew-tapmain 分支。也可以在 GitHub Actions 页面手动 workflow_dispatch 并填 tag 名重跑(比如某次自动触发失败后需要补跑)。
  • 但不能只依赖它:scripts/publish-release.sh 才是发版的默认收尾动作。 GitHub 免费额度的并发上限(20 个并发任务,其中 macOS 只有 5 个,且按账号而非按仓库计)会让整批任务长时间排队——2026-07-30 实测 v0.24.25 的 release 任务排了 45 分钟仍未开始,结果是:配方停在 v0.24.24(用户 brew upgrade 拿不到新版),v0.24.25/v0.24.26 两个 Release 一个附件都没有(install.sh 退化成需要本机 Rust 的源码构建)。脚本在本机几十秒做完这两件事:建 Release → 构建并上传本平台安装包 → 把配方指向新 tag。CI 随后跑完只补齐本机出不了的平台包(在 Linux 上发版就是 macOS 轮子),同名附件覆盖上传,两边不冲突。
  • 发布 checkout 的祖先目录不能有无关 Rust 工作区。 Cargo 会从当前包继续向上找 [workspace];把临时 checkout 放在恰好存在 /tmp/Cargo.toml/tmp/corral-* 下时,即使项目自己的 Cargo.toml 完整,Maturin 仍可能误认父工作区,报 failed to load manifest for workspace member '/tmp/crates/core' referenced by workspace at '/tmp/Cargo.toml',本地收尾会在全量测试通过后才死于构建(v0.24.144 实踩)。这不是 Corral manifest 损坏,也不要删除或改写别的项目的 /tmp/Cargo.toml;把 checkout 移到祖先没有 Cargo.toml 的独立目录(如 ~/.cache/corral-release-<version>)再重跑。若失败前同一次脚本的 CI 门禁已经明确全绿,重跑可带 CORRAL_SKIP_CI_GATE=1;否则不得跳过门禁。
  • 配方回退防护:排队积压时,旧 tag 的 bump 任务可能在新版本发布之后才轮到执行,直接改写会把配方倒退回旧版本(用户 brew upgrade 反而降级)。release.yml 的 bump 步骤与 publish-release.sh 都会先比较配方现有版本,只有不低于现有版本才写入。发现旧 tag 的任务还在排队且已无意义时,直接 gh run cancel 掉,别让它跑。
  • ⚠️ 防回退补丁救不了「补丁之前打的 tag」——排队任务跑的是那个 tag 当时的 workflow 代码,不是 main 的最新版。 2026-07-30 真实发生:防回退逻辑随 b782180 进入 main,但更早的 v0.24.26 那次 release 任务在队列里排了近 11 小时,17:19 本机脚本已把配方推到 0.24.28,20:23 这个用旧 workflow(无防回退)的僵尸任务才轮到执行,把配方硬写回 0.24.26;此后所有 brew upgrade corral 拿到的都是三个版本前的包,且 Actions 页面显示该任务「success」,看状态灯完全发现不了。教训:① 给 CI 加护栏后,必须回头把补丁之前的 tag 上仍在排队/运行的任务全部 gh run cancel,否则护栏形同虚设;② 发版收尾核对不能只看 gh run list 的绿灯和 raw.githubusercontent.com(有约 5 分钟 CDN 缓存),要用 gh api repos/x0c/homebrew-tap/contents/Formula/corral.rb 读实时内容,确认 url 指向刚发的 tag;③ 事后复盘先看 gh api repos/x0c/homebrew-tap/commits——提交时间 + 作者(x0c 是本机脚本、github-actions[bot] 是 CI)能一眼看出是谁把版本写回去的。修复动作就是重跑一次 CORRAL_SKIP_WHEELS=1 bash scripts/publish-release.sh <tag>(只更配方、不重传附件)。
  • 本机脚本与 CI 会抢同名附件,上传失败不得中断脚本。 打完 tag 后 CI 的 release 工作流也在传同一批安装包,而 gh release upload --clobber 是「先删再传」:两边交错时本机这次会拿到 HTTP 404 ... /releases/<id>/assets(附件 ID 在上传途中被对方删掉)。2026-07-31 发 v0.24.29 时实测踩到,脚本因 set -e 当场退出,后面的 Homebrew 配方那一步根本没跑——那次配方是 CI 恰好跑赢才没停在旧版本,等于把「不依赖 CI」的保证白白让了出去。现在上传失败会重试一次、仍失败也继续往下走(配方照更),只是在收尾核对后以非零码退出提醒复查附件数量。判断附件是否齐全用 gh release view <tag> --json assets,别看脚本中途的报错。
  • 配方槽位取源码归档时(该平台无 wheel 的兜底路径)用 GitHub 自动生成的 /archive/refs/tags/vX.Y.Z.tar.gz,其 sha256 理论上由 GitHub 的打包实现决定:2017 与 2023 各出过一次全局 checksum 变动(2023-02 后 GitHub 承诺变更前提前半年通知)。真出现哈希对不上,是重算配方哈希的问题,不是本项目产物损坏。wheel 槽位的哈希来自 Release 的 SHA256SUMS 附件,缺条目时下载附件现算。
  • 该步骤需要仓库 secret HOMEBREW_TAP_TOKEN:一个对 x0c/homebrew-tap 有 contents write 权限的 fine-grained PAT(不要复用本机 gh auth 的个人会话 token,那个权限范围过宽且和 CI 生命周期不一致)。token 过期或权限变更会导致这一步失败,发新版本后应看一眼 Actions 页面确认 bump 任务成功。
  • 实现是纯 urllib + git(生成器 scripts/homebrew_formula.py,见 release.yml),不依赖第三方 Action:mislav/bump-homebrew-formula-actionHEAD /repos/{owner}/{repo}/tarball/{ref} 的重定向只认严格等于 302,但 GitHub 自 2026-05-16 起对该端点的 HEAD 请求改答 303,导致该 Action 必现 unexpected HTTP 303 response(上游 issue mislav/bump-homebrew-formula-action#340,修复 PR #342 长期未合并)。改回用该 Action 前,先确认上游是否已发布修复版本。
  • 不用 Homebrew 的用户走 install.sh(托管在本仓库 main 分支,通过 curl -fsSL .../install.sh | bash 执行):校验 Python 版本、查询最新 Release 的 tag、pip install --user 安装、按需提示把安装目录加入 PATH。改这个脚本后必须实际执行一遍(可用 PYTHONUSERBASE 重定向到临时目录,避免污染真实用户环境),不能只过静态检查。
  • install.sh 依赖 GitHub Release 对象(GET /repos/{owner}/{repo}/releases/latest),不是纯靠 tag。 release.yml 从来只负责 Homebrew 配方同步,从未有过创建 Release 的步骤——早期版本(v0.2.x ~ v0.11.1)的 Release 是每次发布时顺手手动 gh release create 出来的,v0.13.0 发布前有一段时间这一步被漏掉,导致 releases/latest 停留在 v0.11.1 不再更新:install.sh 会静默装出落后好几个版本的旧代码(不报错,只是版本不对),比 Homebrew 配方的哈希校验更容易被漏查。发布新 tag 时必须同时跑 gh release create <tag> --title <tag> --notes <说明>(或等价的 Release 创建动作),发布收尾检查清单里要加一条「curl -fsSL https://api.github.com/repos/x0c/corral/releases/latest 返回的 tag_name 等于刚发的版本」,不能只看 Homebrew Actions 是否绿。
  • 在 suzhou 上验证 install.sh 时,pip install git+https://github.com/x0c/corral.git@<tag> 可能卡在 GitHub clone 并超时(2026-07-06 实测约 130 秒后 Failed to connect to github.com port 443)。这属于该节点直连 GitHub 出口不稳定,不等于安装脚本或 tag 有问题;先原样重试,仍失败时换到 GitHub 出口稳定的环境验证,并在发布记录里明确写出阻塞输出。
  • 三条安装路径(Homebrew、一键脚本、源码安装)在 README.md 里必须保持同步;新增或调整任一路径都要回头检查其余两条描述是否还准确。
  • PyPI 这条路暂时走不通corral 这个分发名在 PyPI 已被一个无关项目占用(corral 1.4,Modular backup script),要上只能改成别的分发名(如 corral-cli),安装命令会和 README / Homebrew / 一键脚本里写的不一致。2026-07-30 评估过一次,结论是不值得为此制造第四种叫法;真要上,先想清楚分发名与命令名的对外说法怎么统一。
  • 已知缺口(未修):用一键脚本装的用户属于 pip 渠道,应用内点更新执行的是 pip install --upgrade git+https://github.com/x0c/corral.git@v<tag>——从源码构建,需要用户本机有 Rust 工具链,而他们当初装的是预编译包、多半没有。安装脚本本身会优先下预编译包,所以「装得上、却升不动」。要修就让 update_command 的 pip 分支也去 Release 里挑匹配当前平台的预编译包(与 install.sh 同一套匹配规则),失败再退回源码。

CI 工作流(.github/workflows/test.yml

Ruff 接入(2026-08-05,v0.24.47/54):lint 步骤固定 ruff==0.16.1(防自行升级后规则集变化),规则集 E/F/I/UP/B 并卡 CI。改 pyproject.toml[tool.ruff] 后本机先 ruff check src tests 清零再提交。三个已知坑:

  • E501 按显示宽度计数,CJK 字符算 2 格——批量修改脚本用 Python 的 len()(按字符数)判断"超长行"会漏掉中文行,noqa 加不上、CI 照样挂;要按 ruff 报的行号精确处理,不要用字符数条件。
  • E402 的豁免边界sys.path.insert 之后的模块级 import 被 ruff 豁免(常见 hack 模式),src 下不会报;测试文件里"先设环境变量/夹具、后 import"的刻意顺序用 [tool.ruff.lint.per-file-ignores]"tests/*.py" = ["E402"] 豁免,不要逐行加 noqa。
  • mock.patch("模块.符号") 的目标路径随符号搬迁同步改——常量/函数从模块 A 搬到模块 B 后,patch 打在 A 的命名空间上不再影响 B 里的引用点,测试会静默变假(如 test_attention_ui.py 的 patch 目标已随关注常量迁到 controllers.attention_reader)。

本机验证漏跑 ruff → 天天发失败邮件(2026-08-07,v0.24.57~0.24.65):不是旧的 Kitty / macOS 挂死复发。v0.24.57tests/test_cache.py 加 WAL 用例时把 import sqlite3 插在 stdlib 与第三方之间的空行后,触发 I001(import 排序);此后每次 main 推送 7 个矩阵作业全在 Lint 步红掉,单测一步都没跑到,邮件却照发。根因是本机发版只跑了当时只管 unittest 的 ci-test.py,与 CI「先 Lint 再 Test」不同源。已修:① 纠正 import 排序;② scripts/ci-test.py 开头先跑与 workflow 同版本的 ruff check,本机绿才算过。以后看见「整矩阵 ~1 分钟就失败、Test 步 skipped」优先查 Lint 日志,不要先怀疑单测偶发。

推送 / 发版门禁 + 矩阵 fail-fast(2026-08-07):单靠文档不够——Agent 仍可能漏跑验证就推。落地三道:

  1. .githooks/pre-pushbash scripts/install-git-hooks.sh 装到 Git 实际会执行的 hooks 目录):日常推送只跑 ci-test.py --lint-only;提交说明以 release: 开头或推 v* 标签时需要完整检查。应急跳过:CORRAL_SKIP_PUSH_GATE=1git push --no-verify(应极少用)。
    • 完整套件每个版本只跑一次(2026-08-30):发版慢不是因为单次检查太重,而是同一套完整检查被连跑最多三遍(发版前一次、推送门禁一次、收尾脚本再一次)。完整检查成功后在 .git/corral-ci-stamp 记下当前产品代码指纹(src/tests/scripts/.githooks/rust/ 及版本文件);推送门禁和 publish-release.sh 发现指纹未变就只再拦 ruff。改过这些目录之后戳失效,必须再跑。禁止把「跑快点」修成跳过界面/终端集成或只跑改过的文件;也不要指望 Agent 每次记得设 CORRAL_SKIP_*
    • 共享 core.hooksPath(如 ~/.git-hooks:Git 会忽略各仓 .git/hooks。安装脚本必须在共享目录写合并分发器:先跑全局 leakgate 泄漏门禁,再仅当当前仓有可执行的 .githooks/pre-push 时转调——禁止写成「只转调 Corral」的旧分发器(会盖掉本机所有仓库的密钥扫描),也禁止把本仓专用脚本直接盖到全局 hooks(否则别的仓库推送也会跑 corral 检查)。若目标已是指向本仓脚本的软链,rm 再写分发器cat > 会顺着软链把真脚本盖掉(已踩过一次)。
    • 判定「是否全量门禁」:只看「相对远端尚未推送」的提交(git log … --not --remotes)。新分支首次推送若用裸 git log $sha,会扫到历史上任意 release: 提交,误跑全量——不要改回。
  2. publish-release.sh 开头认同一枚戳:工作区未改就跳过整套;戳失效或被 --no-verify 绕过推送时仍会跑完整检查,挡住「把配方指到未验证版本」。CORRAL_SKIP_CI_GATE=1 仅应急。
  3. test.yml 矩阵 fail-fast: true:一路挂了就取消其余作业,少收重复失败邮件、少占免费并发。排查「只在某一 OS / Python 挂」时可临时改 false 看全貌,修完改回。

仍无法保证永远零邮件(平台专属挂死、偶发竞态、GitHub 自身异常),但「本机以为绿、一推整矩阵 Lint 红」这类应被门禁拦在推送前。克隆后若尚未装 hook,先 bash scripts/install-git-hooks.sh

多 Agent 并行时的发版卫生(2026-08-08):工作区常有别人半成品(版本号半 bump、未过单测的 WIP)。门禁会因「脏树 / 版本文件不一致」拒推或让 publish-release.sh 半途失败。约定:

  1. 发版前先看清整棵工作区;能一并纳入本次 release 的就纳入,不要只挑自己的文件。
  2. 别人半成品会污染版本号或测不过时:先 git stash push -u(含未跟踪)再 bump / 测 / 提交 / 打 tag / 推送 / 跑收尾脚本;成功后再 stash pop,冲突按「改动即发布」合并进后续版本,禁止丢弃他人改动。
  3. 推 tag 后必须用 git ls-remote --tags origin / github 核对远端真有该 tag;本地 git push 因门禁失败时可能根本没推上去,不能只看本机 tag 列表。
  4. CORRAL_SKIP_PUSH_GATE=1 只允许在:GitHub 侧该版本已验证过(或本机刚跑完完整 ci-test)、且阻塞原因是脏 WIP / 双 remote 重复跑门禁之类非产品缺陷时使用;禁止用跳过门禁掩盖未跑测试。
  5. 显式给旧 tag 跑收尾时,工作区版本号必须等于该 tag。 publish-release.sh 按当前工作区打包,不会切回 tag 源码。2026-08-16 给 v0.24.125 收尾时工作区已被并行 Agent 升到 0.24.126,把 126 的 macOS 安装包传到了 125 的 Release(校验和清单仍是 125 的,附件列表却混了两套)。发现后应立刻从该 Release 删掉版本号不符的附件。脚本现在会在版本不一致时直接退出。

2026-07-31 排查「GitHub 天天发失败邮件」的完整结论。故障从 2026-07-23(v0.24.1)起持续,test 工作流此后没有再成功过一次,三个独立原因叠加:

  • Kitty 键盘协议回归用例在 5 个 Python 版本上全挂(确定性,非偶发)。 TEXTUAL_DISABLE_KITTY_KEY 原先只在 cli.py 顶部 setdefault,而 textual.constants导入时一次性读环境变量定死的:任何先 import textual 再碰 corral.cli 的路径(测试套件、只 import corral 的脚本、第三方嵌入)都会让这道保护整个失效。本机之所以一直看不出来,是因为开发环境的 shell 里已经导出了 TEXTUAL_DISABLE_KITTY_KEY=1,把问题掩盖掉了——复现必须 env -u TEXTUAL_DISABLE_KITTY_KEY 清掉再跑。已修:开关上移到 corral/__init__.py(包顶层是唯一「任何用法必经」的位置),cli.py 不再重复设置。
  • macOS 作业挂死并空烧 6 小时,进而拖垮整个队列。 作业没有配 timeout-minutes,单测跑到 test_ui 后半段卡住后一直占着 runner 直到平台 6 小时上限才被杀。免费额度的 macOS 并发本就少,两个这样的僵尸作业把后续排队拖到 14 小时以上(实测:11:48 推送的作业次日 02:22 才开始跑),连带一大片 cancelled。已加 timeout-minutes: 40,并让 scripts/ci-test.pyfaulthandler.dump_traceback_later 在 1500 秒时打印全部线程栈再退出——下次再挂,日志里直接能看到卡在哪个用例,而不是只剩一句 The operation was canceled挂死点已于当天定位并修复——见下面「macOS 专有挂死」一条,这套打栈机制第一次上线就把它抓了出来(26 分钟自曝,而不是空烧 6 小时)。
  • 已知 Pilot 偶发污染结论。 见「界面」节的分屏聚焦竞态那条。CI 现在走 scripts/ci-test.py,首轮失败的用例自动单独重跑一次,两次都失败才算真回归。
  • 排查「ci-test 跑很久 / 每次都要等很久 / 发版检查跑三遍 / 不要每次都跑这么重 / 是不是卡住了」(2026-08-30):单次完整套件大约十分钟,不是故障。时间几乎都在界面自动化和真实终端集成;日常推送只跑几秒的格式检查。还在刷新的通过行、或夹杂「任务执行超过 0.1 秒」= 仍在跑。连续许多分钟零输出、或约 25 分钟打出全部线程栈才是挂死(见上条 macOS 空烧,已修)。发版若连等三轮,是门禁在重复跑同一套(已改为认戳跳过)。禁止把「发版门禁太慢」修成跳过界面/终端集成。

另外两处工作流层面的浪费也一并修了:on: push 不带过滤时,tag 推送会和同一提交在 main 上的推送产生完全重复的一轮矩阵(每次发版凭空多 7 个作业),已收窄为 branches: ["**"];并加了 concurrency + cancel-in-progress,同分支后推的提交自动作废前一轮排队。

改这个工作流或 scripts/ci-test.py 后,本机至少验证:env -u TEXTUAL_DISABLE_KITTY_KEY python scripts/ci-test.py 全绿,且用临时目录造一个「首轮失败、重跑通过」和一个「两轮都失败」的假用例,确认退出码分别是 0 和 1。

macOS 专有挂死:TCSADRAIN 在无人读取的伪终端上永不返回(2026-07-31 已修)

OscProbeFlushTests 那两个 pty 用例在 macOS runner 上百分之百挂死,Linux 上怎么跑都不复现。栈精确停在 theme._probe_osc_colours 收尾的 termios.tcsetattr(fd, termios.TCSADRAIN, old)

  • TCSADRAIN 的语义是「先等输出队列排空,再让新属性生效」。 探测会先把 OSC 10/11 查询写给 pty slave,这些字节堆在输出队列里等对端来读。测试只造了一个「会写应答」的线程,从来没有人读 master,队列永远不空,于是这一行永远不返回。真实终端不存在这个问题——终端一直在读。
  • 不要试图在 Linux 上复现:两边的排空语义不同,Linux 直接返回。这类「只在某一个 OS 上挂」的问题,靠本机重跑是抓不到的,必须让 CI 把线程栈打出来(正是 scripts/ci-test.py 存在的理由)。
  • 修法在测试侧:tests/test_ui.py_draining_pty_master() 上下文管理器在探测期间持续读空 master,模拟真实终端。验证它没退化成空转的最小办法:不开 drain 时往 slave 写几个字节,select master 应当可读;开 drain 后同样的写入应当被吃掉、master 不再可读。
  • 顺带暴露的产品侧风险(未改,知悉即可):同一行 TCSADRAIN 在真实终端被流控卡住时(用户按了 Ctrl+S、或 SSH 链路停顿)同样会阻塞,表现为 corral 启动时整个卡住、TUI 出不来。改成 TCSANOW 可以规避,但会让刚写出的查询字节在输出后处理模式变回去之后才发出,需要在真实终端上验证过再动,不要凭推理改。

macOS 上项目扫描用例全线假失败:/var 是软链(2026-07-31 已修)

test_projects 有 7 个用例只在 macOS 挂。macOS 的 /var 是指向 /private/var 的软链,tempfile 交回 /var/folders/...,而 projects.scan_git_roots 会如实解析成 /private/var/folders/...(解析软链是既定行为,test_scan_resolves_symlink_root 专门守着),两边字符串对不上。修法是 tests/test_projects.py_temp_root():临时目录先 resolve() 再当断言基准。

这类「只在某个平台失败」的问题可以在 Linux 上复现,别干等 CI:把 TMPDIR 指向一个软链目录即可等价重演——mkdir /tmp/realtmp && ln -s /tmp/realtmp /tmp/linktmp && TMPDIR=/tmp/linktmp python -m unittest tests.test_projects。修复前失败 6 个、修复后全过,是本次实际用的验证手法。

焦点竞态:焦点被从刚点进去的格子抢回列表(2026-07-31 已修)

现象有两个面:用户点进内嵌会话后键盘却还在侧边栏;以及侧边栏高亮、右上角会话小窗停在旧格不动。两个独立触发点(下面分别是触发点一和触发点二),当时只修了第一个,老偶发纹丝不动。

触发点一:挂载收尾把焦点还给列表时,闸门漏掉了「绕过意图机制的直接聚焦」。

SplitPaneArea._settle_focus_intent 在挂载收尾时会「按挂载前的样子把焦点还给列表」,原先只有 _focus_intent_serial 一道闸门——而那个计数只在焦点意图经 _apply_focus_intent 兑现时才推进。用户点击、或代码直接 EmbedPane.focus() 是绕过意图机制的,推不动计数,于是迟到的 _on_focus_list() 照常执行、把焦点抢走;连带 PaneCell._notify_pane_focusedcall_after_refresh 延后执行)读到 has_focus_within=False静默丢弃通知且不重试,高亮和小窗就此停住。修法是补第二道闸门 any_embed_focused() 现查,两道各管一种时序(注释里写清了为什么缺一不可)。

排查这类竞态的两条教训:

  • 不要试图在 DescendantFocus 上推进计数。 该事件冒泡到 SplitPaneArea异步的,实测常常排在 _settle_focus_intent 之后才送达(PaneCell 自己的处理器倒是先跑,但够不着区域层的计数)。试过,无效。
  • 插桩必须足够轻。Screen.set_focustraceback.format_stack 后连跑 40 轮一次都不复现——格式化栈的开销直接把竞态窗口盖掉了。可行的做法是只记类型名 / sys._getframe(1).f_lineno 这类常数级信息,对照「成功一轮」与「失败一轮」的事件序列差异,抢占者一眼可见。

同一个现象有两个独立触发点,必须分别修,只修一个另一个照样挂——这是本次最容易误判的地方(修完第一个后 A/B 显示老偶发失败率一动不动,才意识到还有第二个):

触发点二:_focus_list() 里的 Widget.focus() 让生效顺序与调用顺序反过来。 MainScreen._focus_list() 原先用 SessionListView.focus(),它走 call_later 排队生效。于是:挂载收尾先调 _focus_list()(把「回列表」排进队列)→ 用户随后点进某个内嵌格 / 代码调 EmbedPane.focus()(也排进队列)→ 队列依次兑现时,较早排队的「回列表」反而落在后面,把焦点从格子上抢走。轨迹里看得很清楚:失败轮次是 FOCUS_LIST | set_focus->EmbedPane | set_focus->SessionListView,成功轮次是 FOCUS_LIST | set_focus->SessionListView | ... | set_focus->EmbedPane。修法是改用 Screen.set_focus() 同步生效,谁后请求谁生效。这条也解释了为什么触发点一的两道闸门救不了它——闸门在调用时刻判断,而这里出问题的是兑现时刻的顺序,判断时焦点还没落到格子上,any_embed_focused() 当然是 False。

test_focusing_split_pane_highlights_matching_sidebar_session 由此从 8/40 失败变为 40/40 全过

A/B 用 PYTHONPATH 指向两棵独立源码树来切换版本(cp -r src/corral 一份、再用 git show HEAD:<文件> > 覆盖出基线那一份),比来回改工作区文件更不容易搞混。核对基线树是否真的是旧代码时,别用「源码里有没有某个标识符」判断——本次差点被骗:那个标识符同时出现在解释性的文档字符串里,in inspect.getsource(...) 一直为真,看着像基线没生效。要比就比实际的代码行。

排查 CI 失败的取证手法(有个反直觉的坑)

  • gh run view --log-failed 在整轮 run 还没结束时会直接拒绝run … is still in progress; logs will be available when it is complete)。而「有作业挂死」恰恰意味着整轮永远不结束——最需要看日志的时候正好看不到,是这次排查最先撞上的墙。绕法是走 API 拿作业级信息,它不受整轮状态限制:
    • gh api repos/x0c/corral/actions/runs/<id>/jobs --jq '.jobs[]|"\(.name) \(.conclusion) \(.started_at) \(.completed_at)"' —— 排队时长和空烧时长全在这两个时间戳的差里;本次「作业显示跑了 6 小时但一步没动」「推送后 14 小时才开始」都是这么看出来的。
    • 同一接口的 .steps[] 能看到卡在哪一步(挂死作业的 Test 步只有 started_at 没有正常结束)。
    • 已经结束的单个作业可以用 gh run view --log --job <job_id> 单独取日志,不必等整轮结束;看日志尾部最后一个跑完的用例名就能定位挂死位置。
  • 判断「是确定性失败还是偶发」不要只看一轮:for r in <多个 run id>; do …; done 把每轮各作业的 FAIL: 行汇总去重后统计——5/5 作业每轮都挂 = 确定性根因,1~2/5 作业零星挂 = 偶发。本次正是靠这个把 Kitty 用例(确定性)和分屏聚焦用例(偶发)区分开,两者修法完全不同。
  • 排队已经积压时,先把注定失败的在跑 / 排队任务 gh run cancel <id> 掉再推修复,否则新提交还得排在僵尸作业后面。

真实路径验证

改标题、排序或列宽后,除编译和单测外,还要做真实路径验证:

python3 -m compileall -q src/corral tests
python3 -m unittest discover -s tests -v

然后用真实会话列表检查前 120 条没有 raw slug、纯命令、省略号或自产标题 prompt;再用真实终端启动一次 TUI 并退出,确认本机 corral 入口指向当前代码。改动会话扫描/预览逻辑时(不限于 Claude/Codex,OpenCode、Kimi 同样适用),至少随机抽查 5 条真实会话跑一遍 scan_sessions/load_conversation,断言没有空文本、字面量 "None"、角色标错或时间戳非单调,不能只信手写的单测小样例。