Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
T
imccyu 6ea1238714 docs(web): the composer bar is one session-maybe slot, not a swapped pair
fix(web): keep one composer bar DOM across the no-workspace transition

The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.

test(web): add the composer DOM-continuity acceptance probe

Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.

docs(web): session-maybe identity is adoption, not hold-forever

fix(web): session-maybe entries adopt the first session, then remount like strict entries

A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.

chore: knip knows the root acceptance probe's playwright dependency

scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
2026-07-30 15:18:06 +08:00

17 KiB
Raw Blame History

Agent Note: Web client Agent-scope 对等模型与供数通道(agents/scope / blank 复用 / provide)

Status: implemented

English | 中文

范围:client Agent scope(actx)与定向事件、client/host 实体化对等模型、空会话 blank 位与复用(connectWorkspace)、per-session 供数通道(sessions.provide)、队列只读镜像(session/queued),以及承载这些能力的 host wire 小件(summary blank 列、host/session-added 帧字段、host/commands-changed 帧)。输入状态机与 slash 管线见输入状态机 note;命令业务面见命令业务面 note。

问题

web client 只有一张全局会话面:slot 全部从根 context 渲染,插件拿不到「当前是哪个 agent/session」的语境;draft 真身埋在 Session 对象里,任何要参与输入的插件都无处下手。要支撑命令/输入体系,平台层必须先回答:

  • 会话交互态(菜单、popup、草稿、在途请求)归谁持有,双会话如何结构性隔离;
  • 「新会话」在 host 实体存在之前是什么——client 要不要为它造一段独立生命;
  • session-scope 组件如何「自己拿会话数据」,而不是层层下传 props;
  • 用户放弃的新会话在 host 侧留下什么,谁来收。

硬约束:host 是唯一真源;一切注册走 ctx.effect disposer;scope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入 session log。

决策

对等模型:client 与 host 同一根状态轴

host 侧 session.create(workspaceId) 一体产出 Session + Agent + cwd(原子大礼包,不拆);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):

  • 会话身份自出生即为 host 真身:sessionId 由 session.create 响应 / host/session-added 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。
  • 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 session.create({workspaceId}),拿到完整实体。
  • 「New Session 且未选 workspace」是纯视图态(一个导航位置),不对应任何 session/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。
  • 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill/…)它与任何会话无异,slash/plan 天然全活。

Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体

runtime agents/scope.ts 与 host dsh-scope 机制层一致(fiber + tag + filter 过滤;不 value-import:host 包携带 scoped-events 的 Events merge,进 client program 撞 Context merge):

  • createScope(ctx, key):no-op plugin fiber + extend({[kScope]: key, [Context.filter]: …})——filter 直接住 actx:untagged listener 全局可收,tagged 只收本 scope。
  • 派发就是 cordis 原语,thisArg = actx 本身:actx.bail(actx, event, req) / actx.emit(actx, event, payload)。
  • Session.bindScope(actx):resolve 铸 scope 时单次配对(重复绑 throw;dropScope unbind),镜像 host Agent.loopCtx——Session 用它自行派发 scoped 事件。actx→Session 反向走 sessions.sessionOf(actx) 一跳(镜像 host 插件 agent.session 用法)。

与 host dsh-scope 的有意分歧三条:

  • filter 住 actx 自身而非独立 carrier:host 包装层护的是「业务 Agent subject 与 scope key 不漂移」(host 事件首参注入 Agent 本体),client 事件 payload 只带 id、无 subject 可护。
  • key 用品牌 SessionId 值比较而非对象身份:host 里 agent.id === session id(1:1 同轴),agent 身份直接复用 SessionId 品牌,client scope 的身份即 wire id。
  • client 是 Agent 身份 scope 而非活对象 scope:cold 会话期 host Agent 对象已 dispose 而 client actx 存活(视野内)——身份轴严格对等、对象冷热有意不同步。

id→ctx 换乘只许三类位置(业务 provider 永不换乘):

  • slot inject 工厂:ctx 不进渲染层,slot 框架交给组件的身份就是 sessionId,经服务 map 换回对象/controller。
  • root 协调服务自寻址:从投影的 sessionId 经 sessions.scope(id) 找回 actx。
  • root untagged listener:按 payload 的 sessionId 查自有 store。

scope 生命周期:挂靠 list mirror,出生即视野、死亡即 prune

Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用):

  • 出生 = 会话行进入 client 视野(list 基线拉取 / create() 本地回声 / host/session-added 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。
  • prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费者)、session-keyed slot store。staged session(= list.current)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。
  • 重开 = lazy 重建实例 + open() 拉 history(host session log 是持久真相)。
  • 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。

blank 位:空会话的可见投影、转正与复用

「实体化但无首讯」的会话经 summary 派生位 blank 治理(派生列而非 header 字段,SessionHeader 保持不可变):

  • host 判据:session.events.length === 0(零日志事件 = 尚无用户消息)。live 会话 summarize() 内存直读;cold 会话恒 false——lazy-create 契约保证 never-appended 会话根本不进 persistence.list()(JSONL/SQLite 两后端均已实证真 lazy),blank 从不落盘。
  • wire 承载两处:SessionSummary.blank 必填列;host/session-added 帧必填 blank 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。
  • client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号:
    • 发送方本地:首次 prompt() 的成功响应翻 false(受理即证明 user/message 已入 host 日志——此点翻转是确证而非乐观;onEngaged 同步更新列表镜像,当前 New Session 行原地转为普通标题,不新增列表行)。首讯被拒则会话保持 blank:与 host 权威对齐、继续显示为 New Session、保持 connectWorkspace 复用资格。
    • 其他端:host/session-status (running:true) 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
    • 重连对齐:session.list 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
  • 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 session.id === sessions.current 的一条,并强制标题为 New Session。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
  • 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。

connectWorkspace:New Session 的唯一入口

workspaces.connectWorkspace(workspaceId): Promise<SessionId>(归属 WorkspacesService——它同时持有 workspace 规范 path 与 sessions 引用):

  • 复用臂:list mirror 中找 blank && cwd == workspace.path(host realpath 规范 canon 直等比较),命中直接返回该 id,不新建。
  • 新建臂:未命中则 session.create({workspaceId}),返回新 id。
  • 未知 workspaceId fail loud(不静默创建到别处)。
  • 解析保证(两臂同契约):promise resolve 时返回的 id 已在 list store 且 sessions.binding(id) 同步可解析——SessionsService.create 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
  • 调用方拿 id 自行 sessions.open;首讯发送就是普通 session.prompt——会话本来就在,失败即普通 prompt 失败,draft 文本还在 machine 里,重试即再次发送。
  • 全局 New Session 按钮默认取 recentWorkspaceId:先比较各 Workspace 内 Session 的最新 updatedAt,无 Session 时回退 Workspace createdAt,同值保持 Host 顺序;只有完全没有 Workspace 时才 sessions.clear() 进入无 session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
  • runtime 启动时订阅首次完整基线:若已有恢复成功的 current session 则保持不动,否则自动 connectWorkspace(recentWorkspaceId) 并 open 返回的 blank session。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。
  • blank Hero 中改选 Workspace 也走 connectWorkspace;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 sessions.open(nextId)。旧 blank 实体不删除,只因不再 current 而从列表隐藏。

per-session 供数:sessions.provide 标准件通道

session slot 组件「自己拿 session 数据」的唯一供数路径。插件以静态描述符 sessions.provide({hooks, props, resolve}) 声明固定键表(重名 key 注册时 throw),resolve(binding) 在确定 session 下物化值并随 scope 拆;web-react standardKit 统一循环把 hooks 格绑成 use<Name> 选择器 hook(observableHook→uSES,防 tearing)、props 格原样透传。

slot scope 是闭集 root | session-maybe | session:

  • root 只拿全局标准件,不接收 session 身份或供数。
  • session-maybe 以收养(adoption)身份语义跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在第一个 session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态由构造保证随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 sessionId、useSession/useInput 的选择结果及 inputActions 均可缺省。根部无 key 的 SessionMaybeProvider 通过订阅 runtime 的原子 currentProvide 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——SessionMaybeProvideInfo 靠静态键表在无 session 时仍保留完整 hook/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 SessionMaybeEntry。
  • session 保证 sessionId、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 sessionId 为 key,切换 session 会重建该 entry 及其 session store。

conversation 是 session-maybe 的常驻外壳:ConversationRoot、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;conversation.session 只承载严格 session 的 header/view。composer bar(conversation.composer.bar)本身即为 session-maybe:无 session 时以惰性态渲染(machine face 缺席、disabled owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 session,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。

  • runtime 内建第一条:'session' hook——useSession 本身走同一机制,无特判。
  • Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
  • 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 SessionStandardProps / SessionMaybeStandardProps)。

队列只读镜像

  • MuxFrame session/queued:Session 持只读 inbox 镜像(预览截断、steering 按 source 匹配退休)。宿主会在实时和回放帧中标记 agent loop 接受消息时的 steering 分类,因此重连基线不依赖回放更早的 turn/start。queue 帧不进 history,纯 stream 态——重连清空、新基线重灌;未实例化窗口经 manager pendingBuffers 缓冲重放。
  • 队列语义:running 不锁输入;普通消息经 session.prompt {mode:'queue'} 排队,命令永不排队。

host wire 小件

  • summary blank 列与 host/session-added 帧 blank 字段(见上文 blank 位)。
  • SSE 帧 host/commands-changed(纯失效信号);client 路由为类型事件 commands/changed 与 connection/reset(连接代建立后广播,wire 派生缓存一律视旧态为 stale)。
  • command.list/execute、skill.list 一律 sessionId 单址(会话恒有 Agent,agentFor 的 resume 语义现成);命令面叙述见命令业务面 note。
  • session.create 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 session-conflict)。

Alternatives considered

弃案 一行理由
client-local Intent + materialize(published CAS / pendingPrompt attach 事务 / before-create 链) client 被迫模拟 host 缺失的前半段生命,养出 published CAS、attach 事务、部分发布一坨状态机
host 预留 ID(draft Map) host 只认了个号,状态机原封留在 client
host draft Session(有 Session 无 Agent) 每个查 Agent 的 host 面都要为 draft 分叉;core 要开 attachAgent 缝 + header cwd 后写
无 cwd 先绑 Agent(ungrouped) header.cwd readonly "created in" 不变性被推翻 + launch-dir 副作用产品坑
React Context 层层传会话语境 插件在 host/client 两侧应是一个心智模型;scope 机制与 host dsh-scope 同构
scopeTarget carrier + 融合派发器(镜像 host agentEvents) host 包装层护的是「业务 Agent subject 与 scope key 不漂移」,client 事件无 subject 可护;filter 住 actx + cordis 原语覆盖全部需求
Session 不持 ctx(对象层 cordis-free) 只为筛选单测不引 cordis 而生的红线,代价是 contribute 两跳回调 + 可变公有字段;host Agent 本就持 loopCtx
Session 实例常驻(resident-instance) host session log 即持久真相;常驻仅为身份便利,与 scope 生命周期错位是复杂度之源
组件收 wiring 回调包(inject→props 两层下传) 标准件通道让组件自取;公共面收敛为 hooks + 稳定 props
Hero 无 session 视图与 session Conversation 整支互换 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动
让 InputBar 自身变成 session-maybe 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界
专用「转正」帧 session-status(running:true) 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息

后果

  • 插件获得与 host 同构的会话语境:per-session 状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
  • client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等 provider 一律以 sessionId 直接寻址。
  • 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 New Session 展示),回收靠 lazy persistence 的既有契约(重启蒸发),常规上限靠同 Workspace 复用。
  • 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。
  • 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host selectModel 三件套现成,等独立分支)。