feat(cli): unify external session imports through Host - #5308
wutongyuonce wants to merge 8 commits into
Conversation
Hold an import to the projection the Ledger materializes. `conversation_text` is the user's words and the model's, so the importer now refuses a transcript whose rows convert to nothing instead of publishing an empty history, and an imported turn that opens on an assistant reply converts rather than being dropped for having no user row. Import and the conversion share one predicate. Refuse a root it cannot prove. OpenCode's `parent_id` was read through a stringifier, so a BLOB decoded to `undefined` and a child session could be imported as a root of its own. Only NULL and the empty string are roots now. Bound the Claude catalog summary read to the page that asks for it. A summary reads a bounded head and tail instead of each uncached transcript to EOF; the record the opening window cuts in half is finished, so a transcript that opens on one long prompt stays visible. Reconcile an unknown import outcome against a baseline taken immediately before the request. A copy another client made while the picker was open can no longer be mistaken for this import's, and nothing opens unless the source's published count grew and exactly one id is new with none dropped. Give the OpenCode bounds evidence. `scripts/opencode-transcript-benchmark.mjs` measures a real database and a scaled replica it builds, and the constants carry the counting convention, the elapsed time and the peak memory. Generated-by: pi
Wire the tolerant OpenCode catalog row decoder. It was written for the page loop and never called, so a row the SQL bounds did not anticipate would have aborted the whole listing instead of being skipped. Bring back the Ledger's steering rule to the shared projection. A user row the runtime wrote as a steering projection is dropped by the conversion, so counting it as conversation let a transcript through that materializes nothing. Reach past the summary window's own end. The head now reads every record that begins inside it and the one that follows, so a transcript that opens on a large housekeeping record keeps its first prompt and its start time. The record count is gone: inside a byte window it could only fire on a transcript fully inside it, which renamed a live session and dated it from a stale timestamp. The tail tests the byte before its window instead of guessing from where the head stopped, so a window landing on a record boundary keeps that record. Read the copy count, not the recent-id window. Two copies landing is two, and the published count says so; the id list is a window of eight, so a source with a full window drops an id whenever a new one arrives and reading that slide as a second import left this import's copy unopened. Only the count growing by exactly one is required now. Ask the catalog for the scope the profile can express. A profile with a Host workspace lists every Session, and `current_workspace` reaches the Host without a workspace there, which it reads as every workspace. Deliver the first catalog row whatever it costs. A row that cannot fit on its own would otherwise have to be stepped over for the cursor to move, and the Session the user asked to see would vanish behind an empty page. Generated-by: pi
`pageExternalSessionSummaries` materialised every summary and then sliced the page out of it, which is the shape the catalog review objected to. The Claude adapter was its only production caller, and the fix that made Claude order candidates by file metadata before reading them left it behind: only two test files still called it. It is not a seam worth keeping — an adapter that pages through it is paging after materialising, which is what the Host's cursor must not depend on. The export and its unit tests go, and the coordinator test keeps a fixture-local pager that says so. Generated-by: pi
Bound the OpenCode title by what it is for. The catalog's `title` guard was set to the wire's 320 display bytes, so a title past it hid the Session from both pickers and made its import fail — 320 bytes is 106 Chinese characters, which real titles reach. Truncation belongs to the sanitizer and the wire; this bound only has to keep a field nobody typed from being read whole. Count only the Codex rows a page can deliver. The rollout fallback incremented its matched count before resolving the path, so a rollout that vanished between the directory walk and the resolve advanced the Host's cursor past a row the page never returned, and the next page repeated this one's last row. Deliver the first catalog row and end the page there. Appending a row the page budget refused left the running total under-counted for every row after it. Say which limit was reached. The notice interpolated the protocol's own token, so a reader was told the Session exceeds the `record_bytes` limit. Generated-by: pi
|
本 PR 把 TUI 的外部会话接续统一到 Host 已有的 catalog/import 链路:TUI 不再使用本地 scanner 和 下面是与 Issue #5053 规格(正文 + A/B/C 确认评论)以及 一览
D1 · Claude catalog 的底层读取:按候选逐个设界,不做可续游标规格原话(B 项确认):
reviewer 给的最小修复:
原实现: 本 PR:只做后者,不引入可续游标或持久索引。 接口分页统一(TUI 与 Desktop 共用 Host catalog),底层读取按候选逐个设界: 读取上界 = 窗口 512 KiB + 最多 2 条记录。 为什么不做可续游标:那要把 Host 与客户端之间基于 代价(已接受):
代价量级:本机真实语料(4 个 transcript、最大 602 KiB)cold 17 ms / warm 0 ms;摘要按 (mtime, size) 缓存,文件未变不重读。最坏情况是「一页 × 全部候选 × 512 KiB」——在 1128 个 transcript 量级下是数百 MiB 的读,而不是整个语料。 为什么窗口是 head + tail,而不是只读 head:Claude 把给会话起的标题追加在文件后部,实测最后一条标题距 EOF ≤ 16 KiB,只读 head 会让大量会话在列表里退化成用第一条用户提问当标题。 为什么被窗口切断的记录要补齐:一个「第一条就是超长 prompt」的会话会因为窗口内没有完整记录而从列表消失。补齐的上限用导入自己的 per-record bound(64 MiB)。 为什么不另加一个记录条数上限:在字节窗口内部再加计数上限,只可能误伤那些完全落在窗口内的 transcript——实测一份 57 KB、2002 条小记录的会话会因此被改名,并被套上过期时间戳(列表排序会把它当成不活跃会话)。所以字节窗口是唯一的界。 升级路径:若 D2 · unknown outcome 的对账:判据收紧 + 新增一次 catalog 往返规格原话:
规格没有定义「能确认」的判据,也没说基线从哪来(原 TUI 走 scanner + digest,没有这条路径)。本 PR 补足这两点: // packages/cli/src/pi-tui-runner.ts
const before = await readExternalImportState(adapterId, source.id).catch(() => undefined); // ① dispatch 前
...
if (!after || after.importedCount !== before.importedCount + 1) return undefined; // ② 计数恰好 +1
const added = [...after.importedSessionIds].filter((id) => !before.importedSessionIds.has(id));
return added.length === 1 ? added[0] : undefined; // ③ 恰好一个新 id
为什么不用 id 列表的「有无被挤掉」作判据: 代价(已接受):正常路径(导入成功)现在也多一次 catalog 遍历。若源列表很长而目标排在很后面,这次查询要翻很多页,次数与用户当初翻列表找它同量级。换来的是不可能再打开别人的会话;该开销的残留见 D14。 D3 · OpenCode 上限:以实测定值规格原话(A 项确认):
原实现: 本 PR:沿用 64 MiB / 250,000 行,依据由实测定。 计数口径(与
|
| 数据 | 耗时 | 峰值常驻内存增长 |
|---|---|---|
| 真实语料最大会话 288 行 / 0.24 MiB | 1.7 ms | +0.94 MiB |
| 复刻 76,608 行 / 64.54 MiB(= 字节上限) | 317 ms | +216 MiB |
| 复刻 250,272 行 / 210.98 MiB(= 行数上限,放开字节上限) | 1,104 ms | +641 MiB |
读法:真实语料 877 字节/行 → 64 MiB 约在 76,600 行到顶,字节上限先于行数上限触发;第三行那个规模在真实数据上不可达(25 万行塞进 64 MiB 意味着平均 <268 字节/行,那时定价的仍然是字节)。两上限合起来把单次导入的瞬时内存压在 ~216 MiB、耗时 ~320 ms。
峰值内存怎么测的:用 process.resourceUsage().maxRSS(内核高水位),不是定时采样——转换是一整段同步代码,采样器在事件循环被占住时根本跑不到,会严重低估峰值。
代价(已接受):这两个数不是从需求推导出来的,而是从「一次导入可以接受多少瞬时内存」定的——当前机器的实测上限是 +216 MiB。若真实 OpenCode 会话日后普遍大于这个规模,把用户挡在门外会变成日常事件,那时需要重新量,并决定是抬高上限还是改成流式转换。
D4 · 「空」的判定从「消息条数」改成「共享投影」
规格:「不能静默发布任意前缀、尾部、空历史」「synthetic-only 消息不能创建空 turn」。
原实现只检查 Adapter 返回的消息数组是否为空(messages.length === 0),而 Ledger 物化用的是另一套规则:只重放 conversation_text,并且跳过没有 user 行的 turn。两套规则会不一致——一批合法的 tool_call / tool_result / system_note / turn_state,条数非空,换算成对话文本是零;Adapter 直接返回空数组时,原实现也会建出一个没有历史的 Session。
本 PR:抽成一个共用谓词,导入器与 Ledger 共用。
// packages/core/src/session.ts
export function isConversationTextMessage(message: StoredMessage): boolean {
// steering 投影出来的 user 行不是独立 turn,Ledger 会先滤掉它
if (message.type === 'user') return message.steeringEventId === undefined;
return message.type === 'assistant' && typeof message.text === 'string' && message.text.length > 0;
}导入器在持久化之前用它对整段 transcript 判定(一条都不剩 → 拒绝,不建空会话);Ledger 用同一个函数决定导入会话的 turn 取舍。共用一个函数,所以不会出现「一边认为有内容、另一边转出个空」。
typeof message.text === 'string' 是信任边界上的防御:adapter 输出是不可信输入,畸形行不该在这里抛 TypeError,而应由持久化层的 canonical decoder 报出准确的 schema 错误。
D5 · 「没有 user 行的 turn」:导入会话与自有会话规则不同
规格未提及,但实现时躲不掉。
原实现:RuntimeLedgerRepair 一律要求 turn 里有 user 行,否则整个 turn 跳过。
本 PR:
// packages/runtime/src/runtime-ledger-repair.ts
const startsATurn = header.externalOrigin
? turnMessages.some(isConversationTextMessage) // 导入会话:按投影判定
: turnMessages.some((message) => message.type === 'user'); // 自有会话:仍要求 user 行为什么不能统一:同一个形状在两种来源里意思不同。
- Maka 自有会话:一个 turn 一定从用户说的话开始。这里找不到 user 行,说明那条 user 记录被
steeringEventId滤掉了——用户是在一个已存在的 turn 中间插话。把它当独立 turn 转换,会在那个已存在的 turn 旁边凭空多造一个 turn(Ledger 里多一个 run),会话读起来出现两条并行的执行记录。 - 外部导入会话:Claude Code / OpenCode 允许会话以模型回答开头(导出、恢复出来的记录)。这一轮没有用户消息,但模型那段话是真实对话内容。沿用原规则,整轮会被丢掉,导入历史缺一块。
两个方向各有一条测试钉住(导入保留、自有仍跳过)。
D6 · 新增一个可复现的测量工具
规格 A 项要求「说明口径、耗时和内存」,没有指定交付物。本 PR 把它做成仓库里的命令而不是一次性的手算——下次要改这两个上限的人,在常量注释里就能看到怎么重新量。
node scripts/opencode-transcript-benchmark.mjs 读本机真实 opencode.db,并可用 --scale / --scale-bytes 把最大真实会话按倍数复刻到临时库做规模压测。它不是测试:没有断言、不进 CI,只是一把尺子。
D7 · parent_id 的 fail closed
规格:「验证必要表/列、Session id 和 root/child 关系;无法证明不是 child 时 fail closed」。
原实现:
const parentId = stringOf(row?.parent_id); // 非字符串 → undefined → 被当成 root一个 BLOB 或数字形态的 parent_id 会被解码成「没有父会话」,于是一个 child 会话可以作为 root 被导入。
本 PR:
// packages/storage/src/opencode-session-adapter.ts
function readParentId(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined; // root
if (typeof value !== 'string') throw new Error('...cannot be proven a root');
return value.length > 0 ? value : undefined; // '' 是 root,其它字符串是 child
}决定:readSession()(真正导入)严格抛错;listSessions()(列表)容错跳过该行。
列表侧 SQL 本身已把行限定为 parent_id IS NULL OR parent_id = ''(SQLite 存储类排序保证 BLOB / 数字不会等于 ''),所以两处口径一致;这个不对称只在 SQL 边界被未来的 schema 变更打破时才会生效。规格没规定这一点:列表宁可少一行,也不要因为一行坏数据整体打不开。
D8 · 外部 picker 的 scope 跟随 Session 列表
规格:「TUI 可以默认筛选当前 workspace」。
const externalCatalogScope = (): 'current_workspace' | 'all' =>
sessionListScope === 'all' ? 'all' : 'current_workspace';为什么不直接用 current_workspace:current_workspace 请求在 driver 没有 workspace target 时(host-workspace profile、或 --resume 启动)到达 Host 时不带 workspace,而 Host 把它当全局。也就是说 UI 会声称「当前工作区」、实际问的是全部。同一个命令对 Maka Session 列表本来就传 sessionListScope: 'all',所以现在两端问的是同一个问题。
D9 · 分页下推到 adapter,cursor 按 Adapter 原始行推进
规格未规定 cursor 语义。
原实现:Host 调 adapter.listSessions() 时不传分页参数,adapter 返回全部摘要;Host 再用 toWireSummary 丢掉 wire 非法的行(id 含控制字符、超长等),在过滤后的数组上切片,并让 cursor 落在这个数组的位置上。于是「第几个匹配项」在两处指的不是同一个东西,页内出现非法行时 cursor 会错位、漏项或重复。
本 PR:分页下推到 adapter(ExternalSessionQuery 支持 offset / limit,adapter 必须在其存储内分页),Host 的 nextSourceOffset = offset + rawIndex + 1 按 Adapter 返回的原始行下标推进,被丢弃的行不占位。所有 adapter 都遵守这条:计数发生在全部丢弃点之后。
D10 · 分页预算耗尽时第一行无条件交付,且该页就此结束
规格未规定。
原实现:boundedCatalogPage 逐条试算页预算,第一条就放不下时返回空页,而 nextOffset = offset + page.length 等于原来的 offset —— 客户端拿到同一个 cursor,只能重复请求同一页、无法前进。
本 PR:
// packages/runtime-host/src/server/external-session-coordinator.ts
if (fits) { page.push(candidate.session); continue; }
// 第一行无条件交付,并且这一页到它为止
if (page.length === 0) {
page.push(candidate.session);
return { sessions: page, nextSourceOffset: candidate.nextSourceOffset }; // 从它之后继续
}
return { sessions: page, nextSourceOffset: candidates[index - 1]?.nextSourceOffset };两点:
- 第一行无论如何交付,并且该页到它为止——不在预算没计过的基数上继续追加,否则超额不受一行限制。
- cursor 从被交付的那一行之后继续。装了超出预算的第一行时,cursor 是它自己的 offset;装到某一行放不下而停下时,cursor 是上一行的 offset(即从那一行重来)。用错的那个会让下一页跳过中间所有行。
后果:若干行都超预算时,每页只交付一行并且继续前进——请求数变成「超长行数」次,但每一行最终都会出现。代价是慢,换来的是不隐藏任何会话。
为什么实际走不到:一行的每个字段都有 wire 上限——id 512 B、name 320 B、hostCwd 4 KiB、importedSessionIds 最多 8 项且每项 ≤512 B——合计约 9 KiB,远低于 72 KiB 的页预算,所以「一行填满一页」不可能发生。这一条是防未来上限变化的兜底,不是在日常路径上生效的。
这个前提有测试守着:the largest row the wire bounds allow still shares a page 构造每个字段都拉满的一行、要求两行装进同一页;输入故意远大于任何上限,所以到达页面的是上限本身。谁把某个上限抬过页预算,这条测试立刻红,而不是等用户发现列表少了一行。
importedSessionIds 是这里最后补上的一个字段:它原来原样从存储层放上 wire,行长取决于另一个组件的 id 格式。现在每项也过同一条 wire 规则,不合格的丢弃而不是截断——id 是主键,截断出来的 id 指向不存在的会话;丢一项只影响「最近副本」提示,不影响 importedCount,对账也会因此保持「不确定」而不是打开错的副本。
D11 · OpenCode 的 title 界:内存守卫,不是展示上限
规格要求 id / title / cwd「在进入 JS 前有界」,没说这个界用来做什么。原实现对这三个字段没有界。
本 PR 加了界,并把 title 界定为 64 KiB。 展示长度的权威在别处:sanitizeExternalSessionTitle 取 120 code points,wire 层再截到 320 字节,两者都是截断;这个界只需要小到「没人输入过的字段不会被整个读进内存」。
为什么不能把界设成展示上限(320 字节):320 字节是 106 个汉字,真实标题够得着。超过它的会话会从两个界面的选择器里静默消失,而且 readSession 直接拒绝导入——同一份标题在 Claude / Codex 那边是被截断后正常列出。截断属于展示层,不属于这里。
D12 · 超限提示只说人话
规格要求超限 / schema / malformed 映射为「稳定的 source_unreadable 和脱敏文案」。
本 PR 把限制类型映射成本地化名称(三种语言),用户读到的是「超过支持的记录数量上限」,而不是协议令牌 record_bytes。Desktop 本来就有这份映射,TUI 补齐一致。
D13 · 对账只认已发布副本:半成品在那个窗口里不可见
规格:importedCount / recentSessionIds「根据当前仍存在且已发布的 Session 动态计算」。规格没说这条规则在「结果未知」的对账上意味着什么。
决定:沿用已发布口径,不做例外。
为什么:一个导入 Session 先以 transcriptLedgerVersion: 0 落库(记录在、历史还没转换完),物化完成才置 1。半成品既不能打开,也不应该被算作「你已经有一份了」,更不应该出现在 Session 列表里——否则用户点进去会看到空历史。
代价(已接受):如果导入在「已落库、还没物化完」这个窗口里返回未知结果,客户端对账时计数不会增加(新那份还是版本 0),于是落到「结果不确定」;而那一刻 Session 列表里也看不到它。几秒后物化完成,副本自己会出现在列表里——没有丢,只是当时不可见。如果用户在这中间立刻重试,会多建一份副本(这符合「每次显式导入都新建独立副本」,只是不是他想要的)。
为什么不关掉它:需要让协议能表达「存在一份正在准备中、属于这个请求的副本」(例如导入请求带请求 id,之后可凭 id 查它创建了哪个 Session)。这是协议扩展,不在本 PR 范围。
D14 · 对账用计数推断归属,归属没有被证明
规格未规定。决定:客户端判断「新副本是不是我建的」的唯一依据是「已发布计数恰好 +1 且恰好一个新 id」。
为什么只能是推断:协议能回答的是「这个源会话一共有几份副本」和「最近几份的 id」,回答不了「哪一份是这次请求建的」。用一个计数推断归属本质上是启发式。
代价(已接受):基线在 dispatch 之前即时读取(D2),所以只有在基线读取与对账之间发生的并发导入才会造成误判——如果那一刻恰好有另一个客户端也导入了一份(或 Host 重启把一份旧的 staging 发布出来),计数依然恰好 +1、也恰好一个新 id,客户端会打开那一份,即打开了别人的副本。两份内容来自同一个源会话,所以看起来很像。
为什么不关掉它:消除它需要 Host 在导入响应里携带可校验的原属证明(请求 id / 幂等键)。而 commit_outcome_unknown 的定义就是「响应没收到」,所以这必然是一次协议扩展。
D15 · catalog 按文件 mtime 排序,展示记录时间戳
规格未规定。决定:排序用文件 mtime(一次 stat),展示用记录里的 updatedAt(要读文件才有)。Desktop 的导入页会显示时间,TUI 选择器只受顺序影响。
为什么:排序必须对全部候选都有一个时间;若按记录时间排序,就得先把每个 transcript 读一遍——正是 D1 要消除的成本。展示的时间是给人看的,记录时间戳(「这段对话最后一次说话是什么时候」)比文件系统痕迹更有意义。
代价(已接受):当文件被对话之外的原因改动过(从备份恢复、rsync 同步、复制移动、只追加了一条元数据记录),两者会错位:一行显示「2 天前」却排在显示「1 小时前」的那行上面。只影响观感——顺序仍然稳定、不丢数据、不影响导入结果。
为什么不把显示改成 mtime:那样顺序与显示就一致了,但显示的时间会变成文件系统痕迹——从备份恢复过的会话会显示成一个假的新时间,比现在更误导。为什么不给当前页读完记录时间:顺序仍是 mtime 顺序,只是每行时间变准,错位依旧存在,白付一次全量读取。
其他由实现确定的细节(规格未规定)
| 细节 | 说明 |
|---|---|
| 导入器在提交前判定,不在提交后 | 投影为空的 transcript 在 createImportedSession 之前就被拒绝,所以不会留下 staging、也不会走到 commit_outcome_unknown 那条错误码 |
| 暂存与发布 | transcriptLedgerVersion: 0 暂存 → Ledger 物化完成才置 1;启动时 recover() 重扫版本 0 的会话;物化失败走 #discardImportedSession(沿用 Host 既有行为,未改) |
| Codex 无 state DB 的回退 | 按目录 / 文件名(ISO 时间戳前缀)反向字典序流式遍历,不再先递归构造整棵 rollout 列表 |
| Claude 文件候选排序 | 先按文件元数据排候选顺序,只读够填满当前页的摘要;摘要按 (mtime, size) 缓存 |
| 并发导入合并 | 沿用 Host 既有的 importsInFlight,key 与语义均未改:只合并进行中的重复请求,完成后再导入建独立副本 |
| OpenCode 连接与快照 | 只读打开经 realpath confinement 的 opencode.db,固定 SQL identifier + bound values,整次读取在一个 deferred 事务快照里 |
删除面
packages/core/src/foreign-session.ts (731 行)
packages/core/src/__tests__/foreign-session.test.ts
packages/core/src/__tests__/foreign-session-timeclip.test.ts
packages/storage/src/foreign-session-store.ts (728 行)
packages/storage/src/__tests__/foreign-session-store.test.ts
同时消失并已 git grep 确认无残留:MakaForeignSessionReader 接口、createForeignSessionStore() 的生产调用、<foreign-session-digest> handoff、MAKA_IMPORT_* flags、两个 package.json 的 export 条目。
A page whose first candidate does not fit the encoded-result budget delivers that row anyway, and its cursor was then taken from the row *before* it — which does not exist. The cursor fell back to the caller's `offset + page length`, so the next request started fifteen rows past the one row the page returned and every row in between was silently dropped from the catalog. Several such rows in a row would repeat it, losing fifteen rows per page. The page now resumes after the row it took. That row's own `nextSourceOffset` is the cursor, where a page that stops before a row it could not take resumes at that row's predecessor's — the same value the in-page cursor already carries. Exported the assembler so this is pinned by a test: the case cannot be reached through the request path, because the per-field wire bounds cap one row far below the page budget. Generated-by: pi
A page is only a packing limit while one row is bounded well below it, and the last field that was not is the imported copy ids: they went onto the wire as the store returned them, so a row's size depended on another component's id format. Each id is now held to the same wire bound as a source id, and dropped rather than truncated — an id is a key, and a truncated one resolves to nothing. That leaves every field bounded, which is what makes the over-budget branch unreachable. A test builds the largest row the bounds permit, with inputs far larger than any cap so the caps are what reaches the page, and requires two of them to share one: raising a bound past the budget now fails there instead of quietly costing the catalog a row. Generated-by: pi
hqhq1025
left a comment
There was a problem hiding this comment.
Reviewed exact head a9142b7bdf9369dc7ba8f63d45b176d39a89b73d.
This change replaces the CLI-local foreign-session handoff with a Runtime Host-owned catalog/import path shared by Claude Code, Codex, and OpenCode, including bounded cursor paging, durable imported Session publication, recovery, and TUI selection. The latest commit also bounds every catalog-row field before wire-budget assembly.
I found two blocking P2 correctness issues:
- outcome-unknown TUI reconciliation can claim a concurrent client import and switch to the wrong Session;
- the Codex filesystem fallback pages by creation-path traversal rather than the previous global update-time order, so recently used or newly archived Sessions can be buried behind stale rows.
Validation completed: clean install, build:test, full typecheck/lint/format/ASF checks, Storage 1266 pass / 11 skip, CLI 1053 pass / 3 skip, focused external-session tests 51/51, and a clean merge tree with current main c22768c3b0dc47518f6f8584e864f86f0b1e5379. Runtime Host full tests were 1932 pass / 19 skip / 1 fail; the only failure was the unchanged managed-Bash sandbox integration because this runner rejects both unshare and bwrap. GitHub currently exposes no hosted checks for this head.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
| ): Promise<string | undefined> => { | ||
| if (!before) return undefined; | ||
| const after = await readExternalImportState(adapterId, sourceSessionId); | ||
| if (!after || after.importedCount !== before.importedCount + 1) return undefined; |
There was a problem hiding this comment.
[P2] Do not infer ownership from one post-baseline copy
A single copy appearing after before does not prove that this request created it. Another client can finish one import in the same interval while this request fails before commit; the count still grows by one and exactly one new id appears, so this branch automatically switches to the other client Session. The existing success fixture already models this observable shape: importSession() only throws commit_outcome_unknown while the catalog independently gains one id. The Host needs an operation-specific import identity for reconciliation, or this path must remain uncertain.
| const rolloutPath = await this.resolveRolloutPath(candidate.path, entry.id); | ||
| if (rolloutPath) entries.push({ ...entry, rolloutPath }); | ||
| ] as const) { | ||
| for await (const candidate of iterateRolloutFiles(root, archived)) { |
There was a problem hiding this comment.
[P2] Preserve global update-time order across fallback pages
Reverse-lexical path traversal orders rollouts by creation path, not by candidate.mtimeMs / surfaced updatedAt, and this loop emits all active rows before archived rows. On this head, a 2025 rollout updated today fell behind 17 stale 2026 rollouts onto page 2; exact base placed it first via the global mtime sort. With 16-row Host pages, recently used or newest archived Sessions can be buried behind arbitrarily many stale rows. Use an update-time ordered traversal or index across both roots, and add a page-boundary regression.
Summary
Unify how the TUI and the Desktop App continue an external Session. The TUI
stops using its local foreign-Session scanner and the
<foreign-session-digest>handoff prompt, and imports a chosen externalSession through the Host's existing catalog/import path instead. Every explicit
import creates an independent native Maka Session snapshot, and importing sends
no model request — the next message the user sends is what continues it.
Refs #5053
Verification
packages/core796 tests,packages/storage1266, thepackages/runtimeledger suite 13,
packages/runtime-host1931,packages/cli221 — all pass.biome check,check:asf-headers,check:tui-copyandgit diff --checkpass.
same title a whole-file read produces, and
node scripts/opencode-transcript-benchmark.mjsreproduces the measurementbehind the OpenCode import bounds on a real database.
modules this branch changes:
managed-dependency-environment-crashfailsbecause Node 22 prints a
node:sqliteExperimentalWarning to a childprocess's stderr, and six
code-modesandbox tests are Node-versionsensitive. One UDS test is flaky under full parallelism and passes in
isolation.
AI use
Select exactly one:
Tool(s) and scope: OpenAI Codex(gpt5.6 sol) implemented the unified import path and two review-fix round. pi authored the remaining review-fix rounds.
Checklist
Does this PR entail a change in behavior?