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

Status: implemented

[English](2026-07-25-web-client-session-scope-and-provide-channel.md) | 中文

> 范围：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](2026-07-25-web-input-machine-and-slash-pipeline.md)；命令业务面见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。

## 问题

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](2026-07-25-web-command-surfaces-and-assembly.md)。
- `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` 三件套现成，等独立分支）。
