跳到主要内容

数据截至 (上游 commit 60706feb348c)

04 · 客户端同步:live 求快、fetch 求准

30 秒导读: daemon 一边把 agent 的输出用 WebSocket 实时推给你(为了"快"),一边提供一个分页的历史拉取 RPC(为了"准")。本章讲客户端凭什么不把实时流当真相,以及它怎么用 epoch + seq 把两条来源合成同一份最终一致的时间线——最后看同一份状态怎么长成手机、桌面、CLI、浏览器五种客户端。

前置章节:01 · 一条连接 讲这条 WebSocket 与中继;03 · AgentManager 与时间线 讲 daemon 侧的时间线唯一真相(epochseq、投影)。本章从"这些东西到了客户端之后怎么办"接着讲。


1. 先讲清楚:为什么要两条路

1.1 一句话

live 流负责让你立刻看见字在往外蹦,fetch 负责保证你最后看到的是对的。

1.2 场景化一点

你在手机上盯着一个跑在家里 Mac 上的 Codex。地铁进隧道,WebSocket 断了 8 秒;出隧道重连,这 8 秒里 agent 写了三个文件、发了两段消息。

如果客户端只信实时流,那 8 秒的内容就永远丢了——而且它自己不知道丢了。所以必须有第二条路:一个能问"我停在第 137 条,给我 137 之后的"的接口。

1.3 两条路的分工

通道消息特点能不能当真相
实时流agent_stream(WebSocket 推送)低延迟、可以是增量形状的生命周期更新(工具从 started 变 completed)不能。会漏、会乱序、会重
权威历史fetch_agent_timeline_request/response(RPC)一定返回完整投影后的条目,带 seqStart/seqEnd/sourceSeqRanges能。它就是 daemon 的账本切片

daemon 侧这个契约写在 docs/timeline-sync.md:3-6;实现在 packages/server/src/server/session.ts:6832(handleFetchAgentTimelineRequest)。

1.4 一张顶层图

怎么读:左边是 daemon,右边是客户端;两条箭头进客户端后分别落在两条车道,最后才合并成你看到的那一列消息。

daemon 客户端(app)
┌──────────┐ agent_stream(快) ┌──────────────────┐
│ 时间线 │ ───────────────────► │ head 车道(临时) │──┐
│ 唯一真相 │ └──────────────────┘ │
│ │ ├─► 渲染
│ epoch │ fetch(准/分页) ┌──────────────────┐ │
│ + seq │ ◄──────────────────► │ tail 车道(权威) │──┘
└──────────┘ └──────────────────┘

epoch+seq 定序闸门
(对不上号就丢弃 + 触发补齐)
  • tail:已被权威页面覆盖的历史,每条都带 timelineCursor: { epoch, seq }
  • head:还没拿到权威位置的实时叠加层,可以被整体替换掉。

一句直觉:把 head 当浏览器里的"乐观渲染",把 tail 当刚从服务器 200 回来的那份 HTML。


2. 服务端这一侧给了什么契约

本节只讲客户端要依赖的那几条服务端事实,daemon 内部的时间线实现见 03 章

2.1 fetch 的请求形状

一次拉取由三个字段决定:方向、游标、条数。

字段取值默认(session.ts:6836-6839)
directiontail / after / beforecursor 就是 after,否则 tail
cursor{ epoch, seq }
limit投影后条目数;0 表示"窗口内全部"after0,其余是 200
projectionprojected / canonicalprojected

响应里客户端真正吃的是这几项(session.ts:6877-6917):epochwindowstartCursorendCursorhasOlderhasNewer、以及每个条目的 seqStart / seqEnd / sourceSeqRanges

sourceSeqRanges 是关键。 一次工具调用从 started 到 completed 可能占了 5 个源 seq,但投影后是一个条目。客户端要靠这个区间数组判断"这个投影条目是否覆盖了我 live 已经画过的那几条",而不是傻乎乎地把文本再拼一遍。

2.2 选择性投递:不看的 agent 不推流

老的 daemon 对每个连上来的客户端广播全部 agent 的流。手机上开着 20 个 agent 就是 20 路无用流量。

新协议让客户端声明能力 selective_agent_timeline(packages/protocol/src/client-capabilities.ts:5),然后用 agent.timeline.set_subscription.request 报一个"我现在看得见这些 agent"的集合(session.ts:2187-2202)。

分发逻辑在 forwardAgentStream(session.ts:1156),按 source(同一个 clientId 可能有多个 socket)逐个判定:

// session.ts:1114-1119,原文
if (
supportsSelectiveDelivery &&
!this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId)
) {
continue;
}

这段的意思是:声明了能力、又不在订阅集里的 agent,它的时间线事件直接不发

但"要人注意"这件事不能被静音。 同一个函数在前面把 attention_required 事件改投成独立消息 agent_attention_required(session.ts:1186-1198)——通知照走,时间线不泄漏。

端到端测试把这套边界钉死了(packages/server/src/server/selective-timeline-delivery.e2e.test.ts:154):

测试断言的行为证据行
订阅集外的 agent,capable 客户端收不到时间线:171-177:190-200
从订阅集移除后立刻停推,legacy 客户端仍照收:202-218
重连后订阅集重置为空,不会自动恢复:220-232
attention 走独立消息且 timelineLeaked: false:234-270
同一 clientId 降级成不支持能力后,恢复全局投递:272-286
订阅 ack 只回给发请求的那个 socket:140-152

心跳不是投递门禁。 心跳(设备类型、可见性、聚焦 agent)只用于通知路由;一条过期的手机心跳可以影响你收不收得到推送,但不允许让时间线行从流里消失(docs/timeline-sync.md:22-31)。


3. 客户端凭什么不信实时流

这节是本章的暗线所在:收到一条 live 事件,第一件事不是渲染,是查号。

3.1 定序闸门

每条 live 时间线事件带 epochseq。客户端拿它和本地游标的 endSeq 比,得出五种结论之一(packages/app/src/timeline/session-stream-reducers.ts:277,classifySessionTimelineSeq):

判定条件处理
init本地还没有游标以这条为起点建游标
drop_epochepoch 不同丢弃;若 seq === 1 则认为是全新一轮,清空并重建
drop_staleseq <= endSeq丢弃(按 seq 去重)
acceptseq === endSeq + 1应用,游标推进
gapseq > endSeq + 1不应用,发出 catch_up 副作用

判定表实现只有 20 行,值得一读:

// session-stream-reducers.ts:291-297,原文
if (seq <= cursor.endSeq) {
return "drop_stale";
}
if (seq === cursor.endSeq + 1) {
return "accept";
}
return "gap";

注意 gap 分支不是"先画上再说":processTimelineSequencingGate(:1487)把 shouldApplyStreamEvent 设成 false,并带出一个从当前 endSeq开始的补齐请求(:1533-1546)。

也就是说,一旦发现断号,后面所有实时事件都会被判成 gap 并丢弃,直到权威页面把游标推到位。 宁可空窗一会儿,也不显示一段中间缺了内容的历史。

3.2 三种"我现在算什么状态"

客户端把一个 agent 的时间线分成三态(packages/app/src/stores/session-store.ts:325-352,AgentTimelineState):

状态含义live 事件怎么处理
cold什么都没有正常进 head
painted有条目,但没有一次成功的权威页面只进 head,不推进游标、不触发补齐
synced权威历史已应用走 3.1 的定序闸门

painted 就是下面第 7 节要讲的离线首帧。判定条件是 agentAuthoritativeHistoryApplied 这一个布尔(session-store.ts:342),没有第二个来源。

对应到 reducer,hasAuthoritativeBaseline 为假时,live 事件被塞进一个纯叠加层里,changedTail 恒为 false(session-stream-reducers.ts:1601-1627)。

3.3 用户翻到历史里去时,直接停止应用 live

聊天大纲支持"跳到第 N 个提示词"。跳过去以后拿到的是一段中间的窗口页(planTimelinePromptJump,packages/app/src/timeline/timeline-sync-plan.ts:71;调用方 packages/app/src/agent-stream/chat-outline/use-chat-outline.ts:176),此时 hasNewer 为真。

这种状态叫 detached。reducer 队列的快照里直接把它标出来,处理时整批返回原状态(session-stream-reducers.ts:1658-1669:1846):

// session-stream-reducers.ts:1846,原文
isDetached: timeline.status === "synced" && timeline.newer === "available",

你在读旧消息的时候,新消息不会挤进来打乱你的滚动位置。 这是"live 不是真相"的一个直接好处:随时可以关掉它。


4. fetch 求准:补齐到"没有更新的了"为止

4.1 为什么要分页

一次无界的时间线响应可能超过中继的帧大小上限,所以补齐必须分页。但分页不等于只补一半。

页大小是投影后条目数,常量只有一行:

// packages/app/src/timeline/timeline-fetch-policy.ts:3,原文
export const TIMELINE_FETCH_PAGE_SIZE = 40;

四种计划由一个纯函数文件生成(timeline-sync-plan.ts),没有散落在组件里:

计划函数用在什么时候
取当前尾巴planTimelineTailFetch(:46)打开 / 重连 / 恢复订阅
从游标往后补planTimelineCatchUpAfter(:37)发现 gap、继续翻下一页
往前翻旧的planTimelineOlderFetch(:54)用户向上滚到历史起点
跳到某条提示词planTimelinePromptJump(:63)大纲跳转,mergeWindow: true

4.2 补齐的终止条件

补齐是一个尾递归:只要 daemon 说 hasNewer: true,就立刻用 endCursor 拉下一页。

// packages/app/src/timeline/viewed-timeline-sync.ts:178-184,原文
if (page.hasNewer && page.endCursor) {
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
return;
}
if (page.hasNewer) {
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
}

第二个分支值得注意:hasNewer 为真却没有 endCursor,是协议自相矛盾,直接抛错进重试路径,而不是默默停下来假装同步完成了。

判定"这轮算不算结束"的谓词也是独立的(timeline-sync-plan.ts:82):

// timeline-sync-plan.ts:83,原文
return input.direction !== "after" || !input.hasNewer;

4.3 三个防串号机制

多个 agent、多个面板、来回切换、断线重连——补齐很容易把旧请求的结果盖在新状态上。viewed-timeline-sync.ts 用三样东西挡住:

  • generation 计数。 每次开新补齐都 +1,await 回来先核对代次不符就整个丢弃(:157-177)。
  • pending 泊车。 已经欠下但现在跑不了的请求(未连接、订阅未确认、有 tail 在跑)存进 pendingCatchUps,只在确认和 tail 完成两个点排空(:99-101:186-190)。
  • 请求级去重。 同一个 client + agent + 请求体的 in-flight promise 直接复用(packages/app/src/timeline/fetch-agent-timeline-once.ts:9),key 是 `${agentId}:${JSON.stringify(request)}`(:20)。

4.4 30 秒宽限期

切个标签页、切个路由、把 app 切到后台——如果每次都退订+重新补齐,流量和延迟都很难看。

所以每一次由可见性驱动的移除都先挂起 30 秒:

// viewed-timeline-sync.ts:42,原文
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 30_000;

宽限期内回来,取消定时器,连订阅都没动过(:369-390,publishVisibleMembership)。但断开连接和 dispose 会立即清空宽限队列(:416-424:468-482)——因为订阅本身已经不存在了,留着没有意义。

丢窗口焦点不算不可见;这条约束写在 docs/timeline-sync.md:133,对应到代码就是可见性由面板自己声明 replaceVisibleAgentIds(:402),而不是由系统焦点事件驱动。

4.5 新旧 daemon 的策略只在一个地方分叉

// packages/app/src/contexts/session-context.tsx:95-96,原文
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
return selectiveAgentTimeline ? "selective" : "legacy";
}

legacy 模式下,acknowledged 直接等于 desired,一个订阅 RPC 都不发,但可见性仍然触发权威补齐(viewed-timeline-sync.ts:435-438)。下游的 reducer 完全不知道 daemon 版本这回事。


5. 两条车道怎么合并

这是全章工程量最大的一块,集中在 session-stream-reducers.tsprocessTimelineResponse(:1225)。

5.1 先决定这一页是"替换"还是"追加"

拿到一页 tail 方向的响应时,先和本地已知范围比对,得出四种处置(deriveResumeTailPolicy,:339):

情况判定处置
epoch 变了currentCursor.epoch !== epoch替换,不保留连续性
window.maxSeq === endSeq完全一致discard:显示层零变化,只推进簿记
window.maxSeq < endSeqdaemon 回退了替换,不保留连续性
页起点 <= endSeq + 1重叠或紧邻append:只取比游标新的条目
其余真的中间缺了一段替换,保留连续性

discard 这一支很实用:你向上滚了很远,后台重连触发一次 tail 拉取,结果和本地一模一样——此时不能替换数组,否则你的滚动位置会被打回底部。代码里它连 commit 都是 "discard"(:1399),只把已确认的本地提交 id 挑出来(:1321-1326)。

5.2 投影页覆盖 live 前缀,而不是拼接

假设 live 已经画出了 "正在分析这个函" 这半句,现在权威页面给了完整的 "正在分析这个函数,它有三个调用点"。

绝不能拼接,否则会变成 "正在分析这个函正在分析这个函数……"。做法是:如果这个投影条目的某个 sourceSeqRanges 跨过了当前 endSeq,就去 head/tail 里找那条前缀匹配的助手消息,整条替换(reconcileOverlappingProjectedAssistant,:766):

// session-stream-reducers.ts:786-790,原文
const matches = (item: StreamItem) => {
if (item.kind !== "assistant_message") return false;
if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId;
return projectedText.startsWith(item.text);
};

匹配优先用 messageId,没有才退到"投影文本以现有文本开头"这个前缀判据。推理块(reasoning)走同样的套路(reconcileOverlappingProjectedReasoning,:853),被认领的条目记进 reconciledUnits 集合,后面不再重复应用(:895-928)。

5.3 生命周期条目的合并

有三类条目天生是"同一个东西的两个阶段",替换历史时必须认出来是同一条(packages/app/src/types/stream.ts:437,mergeRetainedLifecycleItem):

条目合并键合并动作
tool_callpayload.data.callIdmergeAgentToolCallItem,保留原显示位置
todo_list尾部同 provider替换 items
compaction尾部第一个 status: "loading"就地翻成 completed,补 trigger / preTokens

compaction(上下文压缩)这一支最典型:

// types/stream.ts:468-471,原文
if (retained.kind === "compaction" && retained.status === "completed") {
const tailIndex = tail.findIndex(
(item) => item.kind === "compaction" && item.status === "loading",
);

反过来,live 事件侧的 reduceTimelineCompaction(types/stream.ts:1370)也有对称保护:收到 completed 但找不到 loading 时才新建条目,找到就地更新;若 loading 存在而更新失败则直接返回原状态(:1283-1298)——不会出现两个压缩条

测试把这个"live 先到、权威后到"的顺序钉死了:bootstrap 页里是 loading、head 里已经是 completed,合并后必须只剩一条 completed(session-stream-reducers.test.ts:752-798)。

5.4 向上翻页:接缝处要缝合

往前翻一页旧历史时,新页的最后一条和当前历史的第一条可能是同一段助手消息被投影切开的两半。mergePrependedCanonicalTail(:648)处理这个接缝:

旧页 [... , A_前半] + 现有 [A_后半, B, C]
└─────┬─────┘
callId 相同 → mergeAgentToolCallItem
都是助手消息 → 文本相接,messageId 向老的借
否则 → 直接首尾相连

对应代码分别在 :687-699(工具调用)、:705-713(助手消息合并)。

另外,向前翻页的接受条件极严:响应的 endSeq 必须恰好等于 currentCursor.startSeq - 1,否则整页丢弃(acceptOlderTimelineUnits,:629-635)。这挡住了"替换发生之后,旧范围的分页响应姗姗来迟"这种情况。

5.5 覆盖范围是区间集合,不是一个数

用户可以跳到历史中段,于是本地会同时持有两段不连续的已加载区间。游标类型因此带了 retainedRanges(:22-33),合并时先排序再合并相邻区间,最后挑出包含当前 endSeq 的那一段作为主区间,其余留作 retained(mergeTimelineCoverage,:70-100)。


6. 时间和"在不在跑"由 daemon 说了算

客户端很容易犯的错:用本地时钟猜 agent 跑了多久,或者靠"最后一条消息之后还没有新消息"推断它还在跑。Paseo 明确禁止这种推断。

6.1 回合活跃度是一个显式状态机

packages/app/src/timeline/turn-liveness.ts 只有两个相位:

snapshot(activeTurn≠null) / stream_open
┌──────┐ ───────────────────────────────────────► ┌──────┐
│ idle │ │ open │
└──────┘ ◄─────────────────────────────────────── └──────┘
stream_close / snapshot(null) / destructive_close

关键规则是带身份的终止事件不能关掉另一个回合:

// turn-liveness.ts:79-84,原文
const targetsDifferentIdentifiedTurn =
current.phase === "open" &&
turnId !== null &&
current.turnId !== null &&
turnId !== current.turnId;
return targetsDifferentIdentifiedTurn ? current : TURN_LIVENESS_IDLE;

老 daemon 发不带 turnId 的匿名终止事件,那种可以关掉当前回合;这是打了 COMPAT 标记的兼容路径(:78:88)。

取消请求的 id 也存在这条记录里,不存在 React 组件里(:108-116),所以一个旧的取消回执不会清掉新的取消状态。

6.2 计时只有一个来源

渲染层要显示"已经跑了 42 秒"。这个起点只能来自回合活跃度:

// packages/app/src/timeline/turn-time.ts:63,原文
const runningStartedAt = params.isTurnActive ? params.activeTurnStartedAt : null;

activeTurnStartedAt 是 daemon 通过快照或 turn_started 事件给的(session-stream-reducers.ts:1810-1814)。时间线条目上的 timestamp 只用来算已完成回合的耗时(turn-time.ts:27-39),而且只有在 isTurnActive 为假时才结算(:64-66)——正在跑的回合不结算,免得每来一条消息就跳一次数字。

一句话记住:在跑的时间来自 daemon 的 turn;跑完的时间来自条目时间戳。两者不混用。


7. 离线首帧:一份声明为非权威的显示副本

7.1 它要解决的问题

冷启动 app,连上 daemon、拉完目录、拉完时间线要几百毫秒到几秒。这段时间给用户看白屏很难受。

解法是本地缓存最后看过的那点内容,先画出来,但把它明确标记成 painted(非权威)。

7.2 存什么,不存什么

packages/app/src/runtime/replica-cache/index.ts 的取舍很克制:

不存原因
最后聚焦的 agent 快照待决权限(pendingPermissions: [],:157)权限是活的决策,过期的不能诱导用户点
它的 workspace + project其它 agent / workspace首帧只需要一屏
时间线尾巴,最多 50 条(MAX_TIMELINE_ITEMS,:28)游标、epoch、hasOlder、权威标记、同步代次这些描述的是完整数据集,而缓存里只有截断的显示数据集
——未被认领的本地提交行(:403-405)本地乐观行不该在重启后复活

最后一行是本节的设计要点:缓存里如果放了游标,恢复后客户端就会以为自己是 synced 的,于是把 live 事件当权威接受,后果是一份被截断的历史加上一堆新事件,中间永远缺一块。

7.3 1 MiB 预算,整宿主淘汰

// replica-cache/index.ts:29,原文
const MAX_CACHE_BYTES = 1024 * 1024;

超预算时不是"删几条消息",而是按最近写入顺序淘汰整个宿主(buildBoundedPayload,:430-441):storedHosts 是 Map,每次捕获都先 deleteset(:425-426),把刚写的挪到末尾;淘汰取 keys().next().value,也就是最久没被写过的那个。

粒度选整宿主,是因为半个宿主的副本没有显示价值——你不会想看到一个只剩三条消息、workspace 名字还丢了的界面。

7.4 恢复之后怎么被"正名"

冷启动

├─ restore() → restoreSessionReplica → status: painted
│ (只在 session 不存在时写入,session-store.ts:807-811)

├─ live 事件到达 → 只进 head,不推进游标,不触发 gap 补齐

└─ 第一次 tail 响应成功
→ 原子建立 items + range + hasOlder
→ status: synced,head 里的行与权威页面对账

注意恢复路径的幂等保护:restoreSessionReplica 发现 prev.sessions[serverId] 已存在就直接返回(session-store.ts:785-787),所以缓存永远不会覆盖一个活着的会话

第一次请求仍然是普通的 tail,没有为缓存开专用协议——这也是为什么缓存能安全地"随便过期"。


8. 同一份状态,五种客户端

上面所有逻辑都在 packages/app(Expo,跨 iOS / Android / Web)。其余形态是在它外面套壳或换一条更瘦的路径。

形态代码位置用哪条同步路径多出来的问题
手机 apppackages/applive + fetch 全套后台/前台切换、宽限期
浏览器 web同上(Metro web)同上首帧连接提示注入
桌面 Electronpackages/desktoppackages/app同上多窗口、内置浏览器、键盘边界
CLIpackages/cli一次 fetch + 裸 agent_stream
daemon 自带 web UIpackages/server/src/server/web-ui.ts 托管 app 产物同 web要告诉页面"连我自己"

8.1 桌面:多窗口的三个约定

只有第一个窗口恢复几何。 后开的窗口(⌘N、第二实例、"在新窗口打开")一律用默认尺寸让系统层叠,免得叠在恢复的窗口上、也免得几个窗口抢同一个 window-state 存储(packages/desktop/src/main.ts:731-738)。

"打开哪个项目"是按 webContents 编号寄存的一次性信物。 PendingOpenProjectStore(packages/desktop/src/pending-open-project-store.ts)只有 set / take / delete,take 读完即删。窗口关闭时连带清理(main.ts:769-774)。用编号做键,是因为同一时刻可能有好几个窗口正在加载各自的项目。

内置浏览器的 guest 页面被按死在最低权限。 will-attach-webview 里先验证这是不是 Paseo 自己的浏览器 webview,不是就 preventDefault;是的话把权限逐项写死,并且把宿主传来的 preload 全部删掉再换成自己的键盘 preload(main.ts:791-811):

// packages/desktop/src/main.ts:759-765,原文(节选)
delete webPreferences.preload;
delete params.preload;
delete (webPreferences as { preloadURL?: string }).preloadURL;
delete (params as { preloadURL?: string }).preloadURL;
webPreferences.preload = getBrowserKeyboardPreloadPath();

键盘边界的规则是"页面优先"。 guest 里的按键先归网页;只有匹配了显式前缀策略的组合才上报给宿主转发,而且要求 isTrusted、未被 preventDefault(packages/desktop/src/features/browser-keyboard/guest-preload.ts:61-64)。策略里 editable: false 的前缀在输入框里自动失效(:22),所以你在网页表单里打字不会被应用快捷键抢走。

只有三个组合是宿主保留的:⌘L 聚焦地址栏、⌘R 刷新、⇧⌘R 强制刷新(packages/desktop/src/features/browser-keyboard/policy.ts:183-205)。

8.2 CLI:Docker 式命令族

packages/cli/src/cli.ts:51(createCli)注册的顶层命令刻意长得像 docker:

命令对应 docker作用
lsps列 agent
runrun起一个 agent 跑任务,-d/--background 后台
attachattach接上输出流
logs -flogs -f看/跟随时间线
stop / deletestop / rm停止 / 删除
inspectinspect详情
waitwait阻塞到结束

裸调 paseo 加一个目录会被 classifyInvocation 判成"打开项目",直接拉起桌面 app(packages/cli/src/run.ts:22-24);不带参数则等价于 onboard(:29-33)。

attach 的同步策略比 app 简单得多,因为终端本来就是只进不退的一条流(packages/cli/src/commands/agent/attach.ts:103):

  1. 一次投影拉取打印已有内容,limit: 0 表示"窗口内全部"(packages/cli/src/utils/timeline.ts:12-20);
  2. 然后 client.on("agent_stream") 直接打印(attach.ts:155-161)。

没有 seq 闸门、没有 gap 补齐。 CLI 不声明 selective_agent_timeline 能力,所以它落在 daemon 的 legacy 全局投递分支上——正是 e2e 测试里那个 legacy 客户端的角色。这是有意的取舍:终端场景丢一条打印比维护一份最终一致的状态便宜得多。

8.3 daemon 自带 web UI:首帧就知道该连谁

daemon 用 express 中间件把 app 的 web 产物托管起来(packages/server/src/server/web-ui.ts:149,createWebUiMiddleware),排除 /api//mcp//public/ 三个前缀(:6-7),其余全部走 SPA fallback 到 index.html(:73-96)。

有意思的是最后一步:发 index.html 前往 </head> 里注入一段脚本(:253-273):

// packages/server/src/server/web-ui.ts:264-267,原文
const hint = {
listen: host,
useTls,
label,
};

客户端启动时读这个全局变量(packages/app/src/runtime/host-runtime.ts:1303,readInitialDaemonConnectionHint;键名在 :1276),bootstrap 阶段优先用它探测连接,成功就不再去猜 localhost(:1421-1428)。

于是"在浏览器里打开 daemon 地址"这件事零配置:页面是谁发的,就连谁。缓存头也是对的——index.html 永不缓存,带内容哈希的资源 immutable 一年(:130-140)。


9. 巧妙之处

  • 把"实时"降级成一个可丢弃的车道。 head/tail 双车道 + hasAuthoritativeBaseline 让"我现在能不能信 live"变成一个布尔,而不是散落各处的 if(session-stream-reducers.ts:1853-1859)。
  • 完整性有一个可执行的定义。 不是"拉了一页就算好了",而是 hasNewer === false(timeline-sync-plan.ts:91);拿不到 endCursor 却说还有更新就抛错(viewed-timeline-sync.ts:217-219)。
  • sourceSeqRanges 让投影和实时能对账。 有了源 seq 区间,客户端能判断"这一整个投影条目盖住了我 live 画的哪几条",从而替换而不是追加(session-stream-reducers.ts:777-781)。
  • discard 是一等公民。 一次 tail 拉取发现完全一致时,显示层零变化,只推进簿记,保住用户的滚动位置(:1311-1327deriveResumeTailPolicy :354-356)。
  • 缓存故意残缺。 只存显示数据,不存任何描述完整数据集的元数据,于是它不可能被误当成同步检查点(replica-cache/index.ts:264 起的 StoredTimelineSchema 只描述条目与可见范围,:38-40 的 COMPAT 注释还明说缓存按能力渐进升级)。
  • 淘汰粒度 = 一个宿主。 半份副本没有显示价值,不如整份丢掉(:430-441)。
  • 版本分叉只发生在一处。 selective vs legacy 只在 viewed-timeline-sync.ts 分叉,reducer 层完全不知情。

10. 边界与局限

  • 重连后订阅集是空的,客户端必须重报。 daemon 不替你记住上次看的是哪些 agent(e2e 测试 :220-232 明确断言)。
  • CLI 的 attach 没有一致性保证。 断线重连不补洞,输出可能缺一段;这是刻意的。
  • before 分页只接受紧邻的一页。 不相邻的旧页整页丢弃(session-stream-reducers.ts:630-636),所以历史加载在弱网下可能需要多次触发。
  • gap 期间界面会"卡住"。 定序闸门丢弃 gap 之后所有事件,直到补齐完成——为了正确性牺牲了这段时间的实时感。
  • painted 状态不区分新旧。 缓存里可能是几天前的内容,没有过期时间戳;唯一的保证是它会被第一个成功的 tail 响应原子替换。
  • 一堆 COMPAT 兼容路径还在。 匿名回合终止(turn-liveness.ts:78)、投影 before 页所有权过滤(session-stream-reducers.ts:1069-1070)、selectiveAgentTimeline 门(client-capabilities.ts:2-5),都标了移除日期。
  • git / worktree 相关的键控与检出不在本章。05 · 工作区、worktree 与 git 检出

11. 代码地图(导航索引)

主题文件符号
fetch RPC 服务端处理packages/server/src/server/session.tshandleFetchAgentTimelineRequest
投影页选择packages/server/src/server/session.tsselectProjectedTimelineProjection
选择性流投递packages/server/src/server/session.tsforwardAgentStreamusesSelectiveTimelineDelivery
投递契约的端到端证据packages/server/src/server/selective-timeline-delivery.e2e.test.tsisDedicatedAttentionhasTimeline
客户端能力常量packages/protocol/src/client-capabilities.tsCLIENT_CAPS
定序闸门(去重 / 断号)packages/app/src/timeline/session-stream-reducers.tsclassifySessionTimelineSeqprocessTimelineSequencingGate
权威页面应用主入口packages/app/src/timeline/session-stream-reducers.tsprocessTimelineResponse
替换 / 追加 / 丢弃决策packages/app/src/timeline/session-stream-reducers.tsderiveResumeTailPolicyderiveBootstrapTailTimelinePolicy
投影覆盖 live 前缀packages/app/src/timeline/session-stream-reducers.tsreconcileOverlappingProjectedAssistantreconcileOverlappingProjectedReasoning
向上翻页缝合packages/app/src/timeline/session-stream-reducers.tsmergePrependedCanonicalTailacceptOlderTimelineUnits
覆盖区间合并packages/app/src/timeline/session-stream-reducers.tsmergeTimelineCoverage
生命周期条目合并packages/app/src/types/stream.tsmergeRetainedLifecycleItemreduceTimelineCompaction
权威替换与本地行保留packages/app/src/types/stream.tsreplaceWithCanonicalStream
拉取计划packages/app/src/timeline/timeline-sync-plan.tsplanTimelineTailFetchplanTimelineCatchUpAfterplanTimelinePromptJump
页大小常量packages/app/src/timeline/timeline-fetch-policy.tsTIMELINE_FETCH_PAGE_SIZE
可见性 / 订阅 / 补齐编排packages/app/src/timeline/viewed-timeline-sync.tscreateViewedTimelineSyncfetchUntilCurrentVIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS
请求级去重packages/app/src/timeline/fetch-agent-timeline-once.tsfetchAgentTimelineOnce
回合活跃度状态机packages/app/src/timeline/turn-liveness.tsreduceTurnLivenesscloseTurnresolveTurnPresentation
回合计时packages/app/src/timeline/turn-time.tsderiveStreamTurnTiming
三态时间线选择器packages/app/src/stores/session-store.tsAgentTimelineStateselectAgentTimelineStaterestoreSessionReplica
离线显示副本packages/app/src/runtime/replica-cache/index.tsReplicaCachecaptureSessionsbuildBoundedPayload
宿主连接与首帧提示packages/app/src/runtime/host-runtime.tsHostRuntimeStorereadInitialDaemonConnectionHint
桌面 daemon 启动packages/app/src/runtime/daemon-start-service.tsDaemonStartServiceupsertDesktopDaemonConnection
会话装配(策略选择点)packages/app/src/contexts/session-context.tsxgetTimelineDeliveryModecreateViewedTimelineSync
Electron 窗口与 guest 边界packages/desktop/src/main.tscreateWindowwill-attach-webview handler
待打开项目寄存packages/desktop/src/pending-open-project-store.tsPendingOpenProjectStore
内置浏览器键盘策略packages/desktop/src/features/browser-keyboard/policy.tsmatchesBrowserShortcutPolicyclassifyBrowserReservedShortcut
CLI 命令族packages/cli/src/cli.tspackages/cli/src/run.tscreateClirunClicreateCliParseArgv
CLI 流式附着packages/cli/src/commands/agent/attach.tsrunAttachCommandprintStreamEvent
daemon 自带 web UIpackages/server/src/server/web-ui.tscreateWebUiMiddlewareinjectConnectionHint

上游对这套契约的散文描述在克隆的 docs/timeline-sync.md;本章所有结论以源码为准,行号 as-of sourceCommit