corral 维护指南
September 5, 2026 · View on GitHub
标题与排序
- 最近会话排序优先使用历史文件更新时间。用户对“最近”的直觉是最近被续接或写入,而不是文件内部最后一条可解析消息。
- 文件时间不是绝对可信,且污染粒度可以细到单个文件,不一定成批出现:Claude Code 在会话驻留/被重新打开时会追加没有时间戳的元数据条目(
last-prompt、ai-title、mode、permission-mode),把文件 mtime 顶到“现在”而不产生任何新对话内容;Syncthing、复制、批量元数据刷新是同一类问题的批量版本。修正逻辑统一收在models.py的effective_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_VERSION的generation_state=failed与failed_at,保留本地兜底并立即清掉generating状态。冷却期内(默认 6 小时)同一缓存版本不再自动提交模型,避免瞬时故障反复排队花额度;冷却过期或历史失败条目缺少failed_at时允许再入队。提升缓存版本后失败标记也会自然失效。成功、失败和部分缺项都要逐批save_cache,不能只保存成功项,否则缺项会永远重新排队。 - 标题生成后端已抽象为
titlegen.py的TitleGenerator,覆盖与默认运行时注册表一致的六家:claude / codex / opencode / kimi / cursor / pi。titles.py只负责批量 prompt、JSON 解析和缓存,不感知具体 CLI;新增生成器只在titlegen.py加实现并注册进_GENERATORS(候选集合与default_registry对齐),禁止在titles.py里写subprocess调用,也禁止titlegenimportruntime/。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里有非空textblock 就展示,不再看stop_reason;stop_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这类嵌套对象字段,同样可能是 JSONnull(key 存在但值为 null),不是只有 Codex 才有这个坑。_extract_text、_entry_time的snapshot取值、_build_session_info尾部循环取 assistant 文本、load_conversation的text_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里字段值可能是 JSONnull(key 存在但值为 null),payload.get(key, "")的默认值只在 key 缺失时生效,取不到 null 场景,会拿到None再被str()变成字面量"None"混进正文。 实测task_complete.last_agent_message为 null 很常见(任务结束但没有最终文本输出,比如被打断/答案已在更早轮次说完),预览页因此显示过多轮" ◆ Codex\nNone"。三处取值(user_message.message、agent_message.message、task_complete.last_agent_message)统一改成payload.get(key) or "",or会把None也兜成空字符串再被后续的if text:过滤掉。改scan/codex.py任何从 payload 取文本的地方都要用这个写法,不要用.get(key, "")。
Codex 扫描
- 新版 Codex 的对话不再只写旧事件流。 真实用户输入和助手答复也会出现在响应记录中,首轮还会带入大段运行环境说明;扫描、标题摘录和右栏预览都必须同时读取两种形态,并跳过这类注入说明,继续找到真实任务文本。否则有真实工作的会话会被误判为「Codex 新会话」,标题服务把空摘录缓存成「新会话 / 空会话」后不再重试。此类泛标题必须视为无效缓存,恢复真实摘录后重新补齐;回归同时覆盖列表项、完整预览和旧缓存重试。
- 同一句真人输入会各写一遍
response_item(role=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_info读session_meta时顺带取payload.get("thread_source"),scan_sessions里thread_source == "subagent"直接continue跳过,和过滤空会话、死 cwd 会话放在同一批前置检查里。 - 判活(
_live_session_ids)曾对每个存活 codex 进程各发一次lsof -p,是首屏超过 1s 硬指标的真实根因:本机实测单次lsof -p <pid>耗时约 500ms,2 个 codex 进程就吃掉近 1 秒,进程越多越慢。改为按平台分流:Linux 直接遍历/proc/<pid>/fd逐个os.readlink找rollout-文件(近乎零成本,不 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的 JSONtext字段(message.data只有角色/时间/finish 等元数据)。OpenCode v1.2.0 起才是这个格式,更早版本的纯 JSON 文件存储不做兼容——官方升级会自动迁移到 SQLite,遗留在老格式的用户极少;本机没有opencode.db时这个运行时的会话列表就是空的,不报错、不尝试读旧格式。 - 只读连接,WAL 库可能拒绝只读打开:
_connect_ro用sqlite3.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 没有可用放行参数"的能力差距不再存在。两条实测得来、写反了就会静默改变用户命令的规则:--auto必须排在子命令之后。主命令的第一个位置参数是项目路径,--auto一旦前置,后面的词就不再被当作子命令:opencode --auto stats实测报Failed to change directory to <cwd>/stats,也就是"在名为 stats 的目录里开 TUI"——不是报错,是静默执行了另一件事,比报错更难发现。- 只有主命令和
run认这个参数。stats/export/auth等子命令带上它会被 yargs 严格校验判为未知参数、用法错误退出(exit=1)。 所以直启透传不能沿用"垫在最前"的默认实现,OpenCodeRuntime.compose_passthrough_argv按"首个参数是run→ 插在它后面;是其它子命令 → 一律不垫;是路径 / flag / 没有参数 → 按主命令前置"分流。回归:tests/test_runtime.py里test_opencode_passthrough_*四条。历史教训仍然成立:以本机实测行为为准,不要以文档为准——1.15.11 时期官网已在写--auto,而本机两种写法都报错退出;这次是反过来,升级后文档与实测终于一致。旧版本不再做降级兼容(机主 2026-08-04 拍板:不认--auto就是该升级 opencode)。
Kimi 扫描
- 历史按「工作区 / 会话」两级目录存放,不是单文件:
~/.kimi-code/sessions/<workspace_id>/<session_id>/,元数据在state.json(title、isCustomTitle、workDir、lastPrompt、createdAt/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.content里type=="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.jsonl,history_reading_hint说明上面的格式。未来 Kimi 若新增交互式预置 prompt 的入口,应把build_new_plan切成交互式,与 claude/codex 对齐。 -y/--yolo在根命令即生效:不像 OpenCode 的危险参数只在子命令下可用,Kimi 的-y主命令直接接受,所以正常放进KimiRuntime.auto_approve_args,corral 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_sessions的limit对默认 cwd 堆和corral-<ident>/隔离目录各算一份。v0.24.139 起托管写入隔离目录,mtime 最新;若仍按全树凑满limit就停,侧栏会只剩最近的 Pi、历史堆被挤掉(2026-08-20 工作电脑)。不要为了「返回条数严格等于 limit」把两套目录混在一个配额里。置顶/分组成员经keep_ids再豁免:侧边栏记忆里的会话即使 mtime 排在配额外也必须出现在列表;项目筛选救不回没扫到的卡。列表身份 = jsonl headerid。回归:test_isolation_dir_sessions_do_not_starve_heap_history、test_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 拦到的裸pi、corral pi直启、界面里新建会话每次都会出现这行,属预期、无害。拆掉--session-id会让落盘 id 与占位卡 ident 不同,分屏组丢成员、组外冒出重复卡(见上一条)。原生恢复走--session <历史文件>、不带--session-id,因此没有这行;用户不想被托管、也不想看到它时,用command pi绕过 shim。 - 判活(
scan.pi._apply_live_flags)只消费有效 claim,不要再按打开的 jsonl / 隔离目录最新文件 / 启动时间配对去猜属主。没有有效 claim 时保持占位或未绑定。其它既有边界仍成立:-p与auth/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 托管;install、remove、uninstall、update、list、config、auth、--export、--list-models必须直通真实 Pi。已有 corral 托管标识时也必须放行,防止嵌套托管递归。
扫描性能
- 首屏延迟目标 ≤1s(已放宽为非阻断,见
AGENTS.md「验证要求」)。 当前路径:main()把store.load()(→registry.scan_all())丢进后台 daemon 线程,同时跑_probe_osc_colours(),再run_app()先画出骨架(空列表 +「+ 新建会话」);MainScreen用@work等store.wait_loaded()后再rebuild。扫描没跑完时页头不得误报「未找到任何会话」——必须等store.loaded。直启/_dispatch_direct_launch等仍可同步预加载后再进 UI。StartupLatencyTests测的是scan_all(50)本身耗时,不是「进程启动到首帧」墙钟。 scan/claude.py/scan/codex.py的scan_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.py的scan_all()用ThreadPoolExecutor并发跑各运行时的扫描:各运行时读的是完全独立的目录、无共享状态,线程池只是为了重叠磁盘 I/O 等待。实现了scan_signature()的运行时可复用上一次扫描。OpenCode 签名必须同时包含数据库/-walmtime 和排序后的(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-caseai-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_title、native_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_tail的max_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_POLL在attention_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/弹窗菜单项_ChoiceItem设ALLOW_SELECT = False;EmbedPane保留默认值用于划词选中+复制(见「内嵌面板」节)。回归:test_ui.py的test_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()仍会给出已被移出 DOM(parent is None)的控件,Textual 8.2.8 的鼠标按下与拖拽两个分支都直接取parent.region。触发条件是启动首屏重建期间点一下鼠标(events.log里两次都是list_rebuildfull 紧接一条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 全量路径一次;② 右栏EmbedPane的tmux resize-window+ 唤醒抓帧同样防抖,拖动期render_line只按当前宽度裁补旧缓存行,不在主线程狂刷 tmux。禁止改成「每次 Resize 都整屏全量重绘」——拖动会卡顿闪烁。回归:test_resize_full_repaint_is_debounced、EmbedPaneResizeTests。 -
窗口缩放后 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_hold、test_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,TextualLRUCache.set驱逐最老项时del self._cache[last[2]]因链表与 dict 不同步抛KeyError,默认_handle_exception直接退出。上游 8.2.8 仍是裸del。修法:ui/textual_patches.py在导入CorralApp时给LRUCache.set打安全驱逐补丁(KeyError时clear()后重试一次);不要在_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()吞KeyboardInterrupt,restore_terminal()直接os.write关闭序列。不要把 Ctrl+R 改成全文搜索。回归:InterruptTerminalRestoreTests。 -
分屏托管窗宽度错位、画面只占约 1/5(2026-08-14):三处独立都能单独造成错宽。①
host_pane_size用(row.width)//count估算、不扣格间距、也不按 Textual1fr取整,新建后马上又resize,正卡在 agent 启动窗口期;漏掉 SIGWINCH 后窗口再无变化,低于 40 列还会被MIN_HOST_WIDTH钉死。②embed.resize经控制通道 fire-and-forget,EmbedPane._host_size记的是「我请求过的尺寸」,请求没落地时后续 Resize 会被当成已应用而跳过。③_projected_embed_sizes把余数堆给末格,TextualHorizontalLayout却是 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_accumulate、test_focus_session_without_target_size_clears_stale_override、test_capture_size_prefers_tmux_real_size_over_widget、test_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_viewer、test_host_size_heal_grows_back_when_shrunk、test_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_size、test_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_cwd、test_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.py的EmbedPaneWheelTests。 -
已结束会话预览滚轮方向(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_end、test_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_bottom、test_same_hosted_identity_skips_remount_keeps_live_grid、test_hosted_registration_keeps_session_active_without_is_alive。 -
新建 Codex 右栏突然变空(2026-07-22,用户实报):空白新建先登记 8 位临时会话键,Codex 写出真实历史后扫描器会用正式 UUID 替换占位卡。旧逻辑只按同一托管名迁移分屏记忆和右栏格,没有迁移侧边栏当前选中键;列表重建找不到旧键便回到顶部「+ 新建会话」,随后选择跟随把仍在运行的右栏覆盖成新建提示。修法:分屏键对齐时返回旧键→新键映射,列表重建在读取旧 DOM 选中键后同步映射并强制选中正式卡;所有主屏列表重建还必须串行,避免后台重扫与交互刷新并发清空、挂载同一批条目。回归:
test_reconcile_split_keys_after_provisional_becomes_real、test_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=full的list_rebuild(card_count50→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/兄弟空隙/
ListItempadding。会话卡固定三行正文(首行最左关注圆点 + 空格分隔的「项目 标题」/ 运行时靠右 / 时间靠右),不再另加末行空行;单圆点优先级为黄 > 绿 > 红,详情头有文字状态。基准:搜索框高 2、新建项高 2、会话卡高 3。 -
筛选状态只认
nav一份:顶部搜索框写nav.project_query;测试必须断言渲染结果。 -
卡片列宽按终端显示宽度计算(
corral.textutil.text_width/fit_cell,包顶层兼容名corral._text_width/_fit_cell),不要用字符数ljust。 -
主界面同时消费进程活性与会话关注状态,但两者不同:
live只表示进程在不在;关注圆点表示等待回答/执行中/新结果未读;titles.status_tag与agent_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等会话动作集中在MainScreen与ui/modals.py;不要再拆第二套「预览页专用」按键分发。侧边栏选中/托管不抢右栏焦点;滚轮按命中区处理。- 新建:侧边栏「+」走
new_session_flow→NewSessionModal(一个弹窗,左栏项目 / 右栏运行时,左宽右窄 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 context的suggested_prompt与 TUIa接力共用同一个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只管生成LaunchPlan,keepalive.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.annotate):wrap_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.mdRequirements),/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_sessions、test_sessions_without_pid_match_unique_managed_name、test_ambiguous_name_match_assigns_neither。 annotate()在liveness.py,调用点分散在三处,故意不做成单一收敛点:store.SessionStore.load()(TUI 列表)、agent_api的cmd_list/cmd_search(直接runtime.scan_sessions拼列表)、resolve_ref(show/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复用这笔证据,避免主线程反复 forkhas-session。判定「会话是否已结束」一律不要传max_age。确认已死后必须forget_alive,否则缓存会把死会话续命。annotate()只要会话列表非空就会打tmux list-sessions;ps祖先链仍只在有 pid 时才跑。 旧逻辑「没有任何 pid 就整段 return」会漏掉 Pi 这种经常扫不出 pid、但 pane 还在的托管会话(侧栏 Enter restart、回车却 attach 回原进程)。空列表才短路。不要为了省一次list-sessions把无 pid 路径加回去。_launch里先无条件尝试attach_plan,keepalive_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 里托管会话总数默认软上限 12(CORRAL_KEEPALIVE_MAX_SESSIONS,旧名SC_KEEPALIVE_MAX_SESSIONS;0关闭压力回收)。超过上限时才动手:只关「不是进行中」且 tmuxsession_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.conf里bind-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_launch(wrap_plan的第三个调用点,与 TUI 的_launch()复用同一套开关语义)。直启没有"已有保活会话"这个概念(每次都是全新会话,ident用keepalive.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_back、test_direct_launch_focuses_pane_even_when_list_already_has_focus。
内嵌面板(embed.py)
界面层迁移说明(2026-07):界面已从手写 curses 整体换成 Textual,
embed.py的 tmux 抓帧/输入转发/控制通道仍保持 UI 框架无关的架构边界,但其性能与生命周期实现会继续演进。旧版入口模块的_run主循环 +_draw_embed_pane被ui/main_screen.py的MainScreen+ui/embed_pane.py的EmbedPanewidget 取代;PairPool(curses 颜色对池)被框架中立的embed.cell_style(cell) -> rich.style.Style取代;translate_key(curses键码)被translate_textual_key(key字符串)取代。下文凡是描述 curses/ncurses 内部行为的部分仅作历史存档;tmux/协议层结论仍适用,但以同节较新的 ControlChannel、Line API 和滚动约束为准。鼠标拖拽跨行选词 + 复制直接复用 Textual 文本选择:拖动高亮,抬起时 Screen 发TextSelected,MainScreen.on_text_selected有选区则copy_to_clipboard(OSC 52);Ctrl+C仍可再复制。无选区时Ctrl+C由EmbedPane._on_key转发给托管会话中断(widgetevent.stop()后 Screen BINDINGS 不会再执行,故中断判断必须留在_on_key)。
- 定位:与
keepalive.py平级的运行时无关层。keepalive 管「把启动计划包进 tmux 保活」,embed 管「不 attach——用capture-pane拿画面、send-keys送按键」,让 TUI 回车后退化成左侧会话列表(固定 ~39 列,ui/main_screen.py的LIST_PANE_WIDTH)+ 右侧会话现场(EmbedPane)。与保活共用tmux -L corral-keepalivesocket 和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.py的RUNTIME_LABEL_STYLES/runtime_label_style(runtime_id)(按 runtime id,不是 display_name)。左栏SessionCard、右栏详情头、对话预览里 assistant 整段(◆ Runtime: 正文及续行)必须共用这一处,禁止再在ui/里另写一份 hex。配色优先一眼可辨、不强制品牌色复刻——Cursor 品牌橙与 Claude 撞色,故 Cursor 用紫:claude=#D97757、codex=#60A5FA、cursor=#A78BFA、kimi=#F472B6、opencode=#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 做-eeditable);之后改src/立刻生效。核对:corral --version/corral diagnose看package_file、loaded_from_checkout、stale_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_system:COLORTERM=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,或 sshdAcceptEnv ... 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的宽度用 Richcell_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。
- 只锚定位置还不够,必须让外层真实光标「可见」——否则中文根本打不进去(2026-07 真机反馈"内嵌 agent 打不了中文"后补的关键修复):
- 托管状态双通道:
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/bg为int | tuple[int,int,int]:SGR 38/48;2 原样保留 RGB,cell_style用rich.color.Color.from_rgb直通真彩色——禁止再加回 curses 时代的_rgb_to_256量化(会把托管 agent 的渐变/主题色打成 256 色块)。字符宽度统一走rich.cells.cell_len(embed._char_width与corral._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-foreground是transparent(alpha 0),语义是"保留原文字前景色、只给背景着色";但get_component_rich_style会把这个 transparent 前景预解析成一个具体颜色,实测这个值恰好等于选区背景色(都解析成#094472),于是整段apply_style后前景==背景、文字隐形。Textual 自己的渲染路径(Content.render_segments→line.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()阻止再冒泡到MainScreen的ctrl+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[?2004h(main()在 wrapper 返回后关),pane 聚焦时识别\e[200~/\e[201~包裹的正文;Textual 版本里这条由框架原生处理并派发为events.Paste事件,EmbedPane._on_paste直接拿到解析好的event.text经set-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[>25u(25 = 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_colours的finally恢复 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若发现\x1b或rgb:特征直接清空(兜底极晚泄漏)。回归:OscProbeFlushTests.test_tmux_settle_drains_late_passthrough_pair、DirectLaunchHostingTests.test_direct_launch_disables_search_focus_until_hosted、test_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入口落盘原始字节,对比 tmuxsend-keys发出的内容;测「输入字节何时到达应用」不要用cat -v的回显判断--应用开启备用屏 / 同步输出后 tmux 会把回显冻住,看起来像「字节没送达」的假象(本次弯路:先误判为 tmux 扣字节,逐个终端模式二分全不命中);正确姿势是独立探针进程置 raw 后读 stdin、带时间戳写文件。回归:RuntimeThemeParserTests.test_lone_escape_is_released_by_tick_not_held_forever、test_stale_unconfirmed_prefix_flushes_to_original_parser。 corral自身界面的深浅色是另一件独立的事:CorralApp.on_mount用corral._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-pane报bg=-1→cell_style映射成bgcolor=None→ Rich 透明),Textual 会把它们透到 widget 底色。EmbedPane若不显式垫底,透出的就是 Textual 主题的$background(textual-dark 下是一种中性灰蓝),整块内嵌画面因此看着发灰——老 curses 版是天然透到终端真实底色的(use_default_colors()的-1),这是迁移引入的回归。修法:EmbedPane.on_mount用corral._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_session用on_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/5678→abab/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,这是它能活这么久的直接原因;以后写这类测试一律用探测函数的真实输出形态。
- 踩坑(2026-07-23):探测超时太短 + 不清输入队列 → 启动泄漏 OSC 应答,搜索框乱码、会话列表被过滤空(真机反馈:启动先闪过一行
以下四条鼠标兼容记录(能力边界、列表页 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 里都不申请鼠标(两机(此实测结论已过时:Claude Code 自 v2.1.88 起默认全屏渲染并申请鼠标捕获,2026-07-19 实测 2.1.214/2.1.215 托管会话全部mouse_any=False),这条转发路径日常不生效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,并保留当前选中会话不变。回归必须同时覆盖 ncursesKEY_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,后者是 TextualWidget的内置二维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的-Nrepeat 只对普通键有效(对 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 边界自然裁剪的(EmbedPane和SessionListView是两个独立 widget,选择不会跨过去),不需要手写裁剪。 -
会话生命周期:列表焦点下
Esc退出 corral 不碰任何托管会话(后台 tmux 里继续跑);面板聚焦时EmbedPane._on_key把Esc以外的按键转发给 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 + fakeclaude夹具——注册 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 调用标识),JSONtool-call要到用户选完才出现。关注信号必须另查这些 protobuf,不能只扫{JSON;其它工具的 field-2 记录没有 field 23,不能当提问。stop/afterAgentResponse只表示本轮生成结束,不得清掉未作答的等待;beforeSubmitPrompt与sessionEnd可以。配对按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 自己在beforeSubmitPrompt、afterAgentResponse、stop、sessionEnd下的条目;保留其他工具条目。写前把原文件备份到 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.json带isSubagent: true(即使未来版本补了title/cwd/prompt_history也必须跳过)。次级信号在store.db的meta表subagentInfo.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>/(不扫 IDEagent-transcripts)。 -
列表轻扫:
meta.json+prompt_history.json(最新在前);path优先指向store.db。 -
完整对话:
load_conversation只读store.dbJSON blobs,提取<user_query>与 assistant 文本。 -
必须读 WAL,禁止
immutable=1(2026-08 真机:预览/小窗漏最新消息):Cursorstore.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_tail、test_conversation_cache_invalidates_when_sqlite_wal_changes。 -
运行时 id=
cursor,可执行文件=agent,auto_approve_args=("--force",);恢复agent --force --resume <id>。 -
同 cwd 多 agent 判活(2026-07 真机实报后修,同日再修串台;2026-07-23 再修 MainThread;2026-08-30 再修子代理改绑父会话):跨助手接力 / 空白新建会在项目目录起无
--resume的agent,同时旧会话可能仍以--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改成MainThread,pgrep -x agent恒为空——live_processes("agent")必须按 cmdline(argv0=agent或cursor-agent/.../index.js,排除worker-server)兜底,否则 live 全灭、占位卡无法退役,侧边栏出现「临时 8 位卡 + 真实 UUID 卡」双份,点真实卡还会再起一份--resume。回归:CursorScanTests.test_live_flags_bind_resume_and_open_store_separately_in_same_cwd、test_live_flags_do_not_bind_blank_agent_to_older_cwd_history、test_live_flags_bind_via_corral_session_env、test_live_flags_prefer_blank_host_over_secondary_resume、test_live_processes_agent_finds_mainthread_renamed_process、test_live_flags_resume_subagent_marks_parent_live、test_live_flags_open_subagent_store_marks_parent_live、test_live_flags_nested_subagent_binds_root_parent、test_live_flags_parent_pid_wins_over_subagent_pid、test_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 全屏接管。 - 两种形态:
- 项目快捷启动
corral <runtime> <project>:第二个位置参数不以-开头时,当作项目名(大小写无关模糊匹配)。命中后在该目录build_new_session_plan(cwd)新建空白会话(不 resume)。0 命中报错退出;多命中时交互终端编号选择,非 TTY 直接失败并列出候选。项目名后再跟其它参数一律报错(避免corral claude subswap -p x语义含糊)——需要透传时用corral claude --…。 - 透传
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、.cache、Library、以及其它常见点目录噪音。回归:tests/test_projects.py的test_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_args(runtime/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_plan用arg 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.py、tests/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_commands、test_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):agent是cursor-agent的后缀,用子串判断会把"只拦了 cursor-agent"误报成"agent 也拦了"。回归:test_agent_is_not_reported_as_shimmed_by_cursor_agent_suffix。 -
agent认出是 Cursor CLI 才拦:官方现行主命令就是agent(cursor-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_cli、test_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_ref(show/context/plan continue共用)在扫描多个运行时时统一走_scan_runtimes辅助函数:ThreadPoolExecutor并发扫描 + 单运行时异常隔离,与runtime/registry.py的scan_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.py的COMMANDS列表里加一份定义——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 -c、eval等解释的命令字符串。这样含空格、引号或 用户需求文本的参数不会被二次解释,也不会把只读计划接口变成命令注入入口。 -
第三方 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_command;search还必须保留score、matched_via和matched_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.py的main()里(那层刻意不 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_sessions里info["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_agent(session_payload里用_trim()硬截断到_SUMMARY_TRIM_LEN,约 120 字),让管家 一眼看懂"这条会话在聊什么",不必为每条候选都多一次corral show往返。这两个字段本来就是扫描阶段 已经提取好的last_user_msg/last_agent_msg(search的 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=1 或 CORRAL_LOG=debug(额外 debug 事件) |
实现模块:observe.py(event / debug / timed / log_exception / save_tui_screenshot)。corral._log_embed_error 转调 observe.log_exception(events 一条 + embed-error 栈)。
事件名约定:scan_all、list_rebuild、host_session、capture_slow(≥100ms)、screenshot、error。默认不写对话正文;敏感字段名会被改写为 <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/、linuxbrew;pipx看解释器 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.py的MainScreen.on_mount起一个@work(thread=True)的_check_for_update,只在is_updatable(channel)为真时才查网络,dev 渠道直接跳过、完全不发请求(源码检出/开发安装因此永远不会被打扰)。有满足条件的新版本时call_from_thread把ui/update_toast.py的UpdateToast切到available状态。点击更新后禁止在 Textual worker 里原地装包:Homebrew / pipx / pip 都可能删除当前解释器或 site-packages,旧界面下一帧再惰性导入markdown_it、rich.traceback等尚未加载的模块时就会以ModuleNotFoundError崩溃(2026-08-14 真机事故,旧进程为 v0.24.99)。点击必须立即app.exit(result=RestartRequest(latest, channel)),让主循环完全结束后再动安装目录;升级失败原因改在恢复后的普通终端里完整显示。 - 升级与重启机制:
RestartRequest是updater.py里的冻结数据类,携带目标版本与安装渠道,与既有的LaunchRequest/NewSessionRequest/None并列作为run_app()的第四种返回值语义。cli.py的main()和_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.pymain()顶层拦截sys.argv[1:2] == ["update"],转发给updater.cli_update())用于不开 TUI 时手动触发升级;故意不放进agent_api.py——那里的架构约束是只读、无副作用命令,update有真实写盘/装包副作用,不符合这条边界。- 真机调试踩坑(务必记住):
UpdateToast(Container子类)最初把状态刷新方法命名为_render,与 TextualWidget基类自身用来计算可绘制内容的内部方法_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子节点挂载,不受"侧边栏末行间隔"硬约定牵连;也不必显式声明 CSSlayers:列表,未声明的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_latestmock、忽略状态、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 层的"有新版本"测试必须 mockupdater对外函数,不能依赖真实网络或真实渠道判定。 - 跑这些单测必须确认加载的是仓库源码:系统
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与 RustCargo.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-code、codex-cli、terminal、tui、session-manager、ai-coding-agent。
一键安装渠道
- Homebrew 配方在独立仓库
x0c/homebrew-tap的Formula/corral.rb,由本仓scripts/homebrew_formula.py整体生成(两个调用方:scripts/publish-release.sh与release.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-tap的main分支。也可以在 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-action对HEAD /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.57 给 tests/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 仍可能漏跑验证就推。落地三道:
.githooks/pre-push(bash scripts/install-git-hooks.sh装到 Git 实际会执行的 hooks 目录):日常推送只跑ci-test.py --lint-only;提交说明以release:开头或推v*标签时需要完整检查。应急跳过:CORRAL_SKIP_PUSH_GATE=1或git 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:提交,误跑全量——不要改回。
- 完整套件每个版本只跑一次(2026-08-30):发版慢不是因为单次检查太重,而是同一套完整检查被连跑最多三遍(发版前一次、推送门禁一次、收尾脚本再一次)。完整检查成功后在
publish-release.sh开头认同一枚戳:工作区未改就跳过整套;戳失效或被--no-verify绕过推送时仍会跑完整检查,挡住「把配方指到未验证版本」。CORRAL_SKIP_CI_GATE=1仅应急。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 半途失败。约定:
- 发版前先看清整棵工作区;能一并纳入本次 release 的就纳入,不要只挑自己的文件。
- 别人半成品会污染版本号或测不过时:先
git stash push -u(含未跟踪)再 bump / 测 / 提交 / 打 tag / 推送 / 跑收尾脚本;成功后再stash pop,冲突按「改动即发布」合并进后续版本,禁止丢弃他人改动。 - 推 tag 后必须用
git ls-remote --tags origin/github核对远端真有该 tag;本地git push因门禁失败时可能根本没推上去,不能只看本机 tag 列表。 CORRAL_SKIP_PUSH_GATE=1只允许在:GitHub 侧该版本已验证过(或本机刚跑完完整ci-test)、且阻塞原因是脏 WIP / 双 remote 重复跑门禁之类非产品缺陷时使用;禁止用跳过门禁掩盖未跑测试。- 显式给旧 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.py用faulthandler.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 写几个字节,selectmaster 应当可读;开 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_focused(call_after_refresh 延后执行)读到 has_focus_within=False 而静默丢弃通知且不重试,高亮和小窗就此停住。修法是补第二道闸门 any_embed_focused() 现查,两道各管一种时序(注释里写清了为什么缺一不可)。
排查这类竞态的两条教训:
- 不要试图在
DescendantFocus上推进计数。 该事件冒泡到SplitPaneArea是异步的,实测常常排在_settle_focus_intent之后才送达(PaneCell自己的处理器倒是先跑,但够不着区域层的计数)。试过,无效。 - 插桩必须足够轻。 给
Screen.set_focus加traceback.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"、角色标错或时间戳非单调,不能只信手写的单测小样例。