Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
imccyu 01ecb43ebc docs: state the Host-face rule for the browser e2e and settle the follow-ups
apps/web/tests/README.md records why these e2e type-check in the Host aggregate
and why importing a Client package there pulls its project tree into the Host
build graph, with mirroring as the standing answer. The Agent Note drops the
directory-picker face split (assessed and declined) and the grep-level gate in
favour of that README.

docs: regenerate the catalogs and retarget the moved declarations

The forwarded-event change moved three owner packages' cordis `Events`
declarations and their branded types into client-safe `./types` modules, and
the settings-scope split moves the shell spec into ui-settings-general. Point
the type-equivalence manifest and the affected Agent Note at those homes,
register the new `remote/*` event scope and the `ctx.settingsScope` service in
the catalog partition, and re-run the generators.

`$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note
fences that quote a bare member signature are marked `ignore-check`: they are
declaration fragments, not compilable units.

refactor(client): make ui-settings the settings domain's base layer

The settings-namespace transport lived in client/runtime, where every feature
could value-import it because runtime is a platform module. It belongs to the
settings domain, but moving it into ui-settings as a shared function fails
twice: the client bundle purity gate forbids cross-plugin value imports, and
ui-settings reached ui-sidebar for its shell, so any feature depending on it
closed a cycle through ui-layout and ui-theme.

Both halves move. `ctx.settingsScope` is now a cordis service — the
collaboration shape the purity gate prescribes, and the service proxy binds
`this.ctx` to the caller, so a bound scope's disposer belongs to the calling
fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its
navigation, and the nav-row projection) moves to ui-settings-general, which
already owns the chrome and the General section. What stays in ui-settings is
what carries no `ui-*` dependency: the scope service and the canonical settings
slot types, `settings.general.item` included. That type was parked in the locale
package precisely because the declarer was unreachable without a cycle; every
registrant now depends on this base layer, so it comes home.

The scope CONTRACT stays in client/runtime: a feature service accepts a scope
through its own signature without depending on the surface that binds it.

The forwarded settings invalidation replaces the deleted client-side
`settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches
`$on` through the gateway's Client half plus the allowlist's type-only subpath
rather than api-remotes' Client face: that face imports a Host-tsdown-generated
artifact, and this package is reachable from the Host build graph through its
callers.

refactor(client): reach the settings transport through ctx.settingsScope

Every feature that owns a preference row switches from value-importing a shared
binder to the settings domain's service, and declares the two injections that
binding needs: `settingsScope` for the transport and `remote` for the forwarded
invalidation it subscribes to on the caller's own context.

The rows stay with the features that own the preferences — Language with locale,
Appearance with ui-theme, Composer Enter with ui-conversation. Only their route
to the transport changes, so no settings surface moves and no feature gains a
dependency on the shell.

The `settings.general.item` slot type now arrives from ui-settings, the base
layer every registrant already depends on, which retires the re-export outlet
ui-theme kept and the parked declaration in the locale package.

client/runtime drops its settings-form and schemastery dependencies with the
transport that used them.

test(client): bind the settings transport in the specs that boot a preference row

Every bench that activates a plugin owning a preference row now supplies the two
services that plugin injects: the forwarded-event port and the scope service.
Specs that exercise no settings path get the minimal doubles; the ones that do
drive their refresh chains through `remote/host-event`, the same signal
client/runtime republishes from a forwarded frame, replacing the deleted
client-side `settings/changed` event.

Also fixes a publication defect the built-invariant gate catches once it runs:
api-remotes' invariant companion shared the allowlist module with the package
index, so rolldown hoisted it into a third chunk beside the two bundled entries
— a file the mechanically derived publication list does not carry, leaving an
installed companion unable to import it. The companion now reads the allowlist
through this package's own published `./types` subpath, which the bundle keeps
external, so each entry stays self-contained.

The dynamic-subscription cast in apiproxy is gone: after the vendored cordis
rescope, `on` accepts the rest-parameter handler directly, and the allowlist's
shape assertion still carries the safety argument.

fix(client): carry the settings-scope move across the release manifests

Rebasing onto the publishable release set replaced every manifest's dependency
block, so the packages this change touches restate their additions in the
workspace-protocol form: the base layer's own transport dependencies, and the
`ui-settings` plus `remote` edges each preference-row owner now needs.

ui-settings-general takes clsx with the shell it received, and client/runtime
drops the settings-form and schemastery dependencies that left with the
transport.

fix(api-gateway): give each $on subscription its own registration and containment

Two defects in the forwarded-event subscription table, both raised in review:

A set keyed on listener identity stored one entry when two callers subscribed the
same function object to the same event, so the first frame reached it once instead
of twice and either disposer silenced the surviving registration. Subscriptions are
now records addressed by registration, which is what "the disposer belongs to the
calling fiber" requires.

A listener declared void may still be `async`, and the synchronous `try/catch`
could not see its rejection: the promise was dropped and surfaced as an unhandled
rejection outside the documented containment. Delivery now attaches a rejection
handler when a listener returns a promise, so both failure modes are logged and
isolated alike.

Delivery also iterates a snapshot, so a listener that subscribes or disposes during
a frame no longer changes who receives that frame, and production matches the
TestRemote double instead of relying on live Set iteration order.

Both fixes are pinned by tests that fail against the previous implementation. The
double gains its own spec for the `$mount` refusal and the unsubscribed-name drop —
per-file coverage reaches it — plus a note that it propagates a throwing listener
where production contains one, so no spec mistakes it for the containment guarantee.

Three prose corrections: `assertJsonArgs` states where its throw actually surfaces
(the emitter's listener containment, not load or emit time), the browser e2e README
names every standing Client import rather than claiming one exception, and two
comments and a test title state the forwarded event instead of the deleted
client-side one.

refactor(remote): deliver forwarded frames through ctx.remote.$dispatch

The carrier used to relay each decoded frame over an internal
`remote/host-event` cordis event so the delivery port could stay off the Remote
contract. The relay was the wrong shape twice over: it put a client-face event
into a scan whose subject is the Host vocabulary, forcing a walk exemption for
something that is not a Host event at all, and it made a direct handoff between
two Client plugins look like a broadcast any plugin participates in.

`TypeRTClientRemote` now carries both roles of one surface — consumers subscribe
with `$on`, and whoever owns the Host frame sink hands frames over with
`$dispatch` — so client/runtime calls the Remote service directly and the event
declaration is gone. A cordis service method is the collaboration shape the
client bundle purity gate prescribes, and it needs no relay to satisfy it.

The trade is that the handoff is now developer-visible: any plugin holding
`ctx.remote` can synthesize a forwarded event. That is the exposure the relay
already had — `ctx.emit` was equally reachable — stated in the contract instead
of hidden behind a private subscriber.

runtime reaches `ctx.remote` through the gateway's Client face rather than
api-remotes': that face imports a Host-tsdown-generated artifact, and this
project sits in the Host build graph.

refactor(api-remotes): keep the allowlist value out of types.ts

`src/types.ts` carries only types by package convention, but it held the
forwarded-event array, so the type-only subpath published runtime code. The
array moves to `src/remote-events.ts` and `types.ts` derives its projection from
it; both compiler faces list both files, so the Host forwarding loop and the
consumer key face still read one declaration and the package's exports are
unchanged.

The invariant companion returns to an empty installer. Its dispatch-shape check
was the only reason the companion imported the allowlist, which made the two
bundled entries share a module: rolldown hoisted it into a third chunk that the
mechanically derived publication list does not carry, so an installed companion
could not import it. Dropping the check retires that coupling along with the
subpath-import and bundle-external workarounds it needed, and the shape the
check enforced at runtime is the part the Host face's `TypeRTForwardableEvent`
assertion already refuses at compile time.

test(ui-task): bind the locale plugin's new injections in its bench

The bench boots the real locale plugin, which now injects the settings-scope
service and the forwarded-event port, so it stayed pending and left `ctx.locale`
undefined. Supplies both doubles like the other benches that boot a plugin
owning a preference row.

docs: close the documentation gates for the forwarded-event surface

Regenerates the two graph catalogs and re-records every bilingual pair this
branch edited. Several pairs needed real work beyond the record:

- The generators write only the English side, so the Chinese sides of
  `event-producer-consumer` and `module-graph` had drifted: the former still
  listed the three deleted client-face events and pointed at declaration sites
  this branch moved into `types.ts` modules, and the latter carried a stale
  dependency graph.
- `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides.
- The pairing contract requires both sides to link the same target, so the
  apiproxy README and the design note now link the English note from both
  languages, and the note's code blocks are byte-identical across the pair
  (a translated comment inside a fence counts as divergence).
- `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane
  documents a discipline reviewers apply, so it belongs in the bilingual corpus
  rather than in the pairing exemption list.
- Four fences in the design note are marked `ignore-check`: each quotes a member
  signature, a union arm, or a snippet that names symbols it does not import, so
  none is a compilable unit.

docs(agent-note): transition the forwarded-event note to implemented

The design shipped in this PR, so the pair moves into `implemented/` and takes
that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`,
and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what
pins the behavior) and `## Consequences` (what the shipped shape costs).

Facts that moved after the proposal are corrected rather than preserved: the
allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`,
the delivery port is `$dispatch` rather than an internal cordis event, and the
invariant companion is an explained empty installer. `Verification` states the
two `$on` defects the review found — independent registration identity and
async-rejection containment — since those are now the properties tests pin.

Supersession is partial, so five active notes stay active and gain a
cross-link each: `web-config-plane`, `web-client-session-scope`,
`config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and
`permission-default-for-new-sessions` each described a frame this change
replaced. Only the mechanism sentence is annotated; every conclusion those
notes own is untouched, and `host/models-changed` remains apiproxy's own
derived frame in all of them.

Also pins the disposer's idempotence: calling one `$on` disposer twice must not
splice a surviving twin registration out from under its owner.

fix: docs

fix: test
2026-08-11 19:25:41 +08:00

18 KiB
Raw Blame History

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

Status: implemented

English | 中文

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

问题

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

  • 会话交互态菜单、popup、草稿、在途请求归谁持有双会话如何结构性隔离
  • 「新会话」在 host 实体存在之前是什么——client 是否必须为它凭空创建独立生命周期;
  • 会话 scope 组件如何「自己拿会话数据」,而不是层层下传 props
  • 用户放弃的新会话在 host 侧留下什么,由谁回收。

硬约束host 是唯一真源;一切注册走 ctx.effect disposerscope 机制与 host 的 Agent scope 架构一致;模型可见 ⟺ 已入会话日志。

决策

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

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

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

Agent scopeactx 是 client 侧 cordis 世界的唯一会话载体

运行时 agents/scope.ts 与 host dsh-scope 机制层一致fiber + tag + filter 过滤;不 value-importhost 包携带 scoped-events 的 Events merge进 client program 撞 Context merge

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

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

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

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

  • 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-addedlazy 首次 resolve 铸 scoperesolution 纯函数、渲染安全)。
  • prune 一次同拆三样Session 实例、scope fiber级联挂在 actx 上的一切消费方)、会话键控 slot store。暂存会话= list.current例外被移除仍在台上时保留冻结只读视图stage 移走才拆。
  • 重开 = lazy 重建实例 + open() 拉 historyhost 会话日志是持久真相)。
  • 遗留 TODOapproval/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 两后端均已实证真 lazyblank 从不落盘。
  • 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 隐藏行,后续复用消化,不做协调。

connectWorkspaceNew Session 的唯一入口

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

  • 复用臂list mirror 中找 blank && cwd == workspace.path && sessionIds.includes(id)——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配CLI命令行界面/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见成员复用修复);命中直接返回该 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() 进入无会话视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
  • 运行时启动时订阅首次完整基线:若已有恢复成功的 current 会话则保持不动,否则自动 connectWorkspace(recentWorkspaceId) 并 open 返回的 blank 会话。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。
  • blank Hero 中改选 Workspace 也走 connectWorkspace;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scopesessions.open(nextId)。旧 blank 实体不删除,只因不再 current 而从列表隐藏。

逐会话供数:sessions.provide 标准件通道

会话 slot 组件「自己拿会话数据」的唯一供数路径。插件以静态描述符 sessions.provide({hooks, props, resolve}) 声明固定键表(重名 key 注册时 throwresolve(binding) 在确定会话下物化值并随 scope 拆web-react standardKit 统一循环把 hooks 格绑成 use<Name> 选择器钩子(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 时 sessionIduseSession/useInput 的选择结果及 inputActions 均可缺省。根部无 key 的 SessionMaybeProvider 通过订阅 runtime 的原子 currentProvide 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布current id 不变时的名册变化也会重发已挂载 bundle而不是把 entry 困在过期的钩子/prop 形状上——SessionMaybeProvideInfo 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key住在 renderer 的 SessionMaybeEntry
  • session 保证 sessionId、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 sessionId 为 key切换 session 会重建该 entry 及其 session store。

conversationsession-maybe 的常驻外壳:ConversationRoot、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:conversation.session.header 在 scrollport 上方承载 breadcrumbtabactionconversation.session 在其内部承载 view ring 与 draft mirror二者共享同一个 session scope chat store。composer barconversation.composer.bar)本身即为 session-maybe:无 session 时,其 machine face 和消息操作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea转为 live其余输入 slot 保持严格 session在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。

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

队列只读镜像

  • 队列语义running 不锁输入;普通消息经 session.prompt {mode:'queue'} 排队,命令永不排队。

host wire 小件

  • summary blank 列与 host/session-addedblank 字段(见上文 blank 位)。
  • SSEServer-Sent Eventshost/commands-changed纯失效信号client 路由为类型事件 commands/changedconnection/reset连接代建立后广播wire 派生缓存一律视旧态为陈旧)。 该 commands 帧及其类型化 client 事件后来被「commands/changectx.remote.$on 原样转发」取代(转发的 Remote 事件connection/reset 不变,本条陈述的「失效而非差分」契约依然成立。
  • command.list/executeskill.list 一律 sessionId 单址(会话恒有 AgentagentFor 的恢复语义现成);命令面叙述见命令业务面 note
  • session.create 请求形状workspaceId/cwd 二选一 + 可选调用方预分配 sessionId同 id 同 cwd 重试幂等,异 cwd 报 session-conflict)。

考虑过的替代方案

弃案 一行理由
client-local Intent + materializepublished CAS / pendingPrompt attach 事务 / before-create 链) client 被迫模拟 host 缺失的前半段生命,养出 published CAS、attach 事务、部分发布一坨状态机
host 预留 IDdraft Map host 只认了个号,状态机原封留在 client
host draft Session有 Session 无 Agent 每个查 Agent 的 host 面都要为 draft 分叉core 要新增 attachAgent API + header cwd 后写
无 cwd 先绑 Agentungrouped 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 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
  • client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」slash/skill 等提供方一律以 sessionId 直接寻址。
  • 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 New Session 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。
  • 代价id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face这段时间内常驻卡片会把激活操作转到 Workspace picker决策)。
  • 已知欠账approval/question 跨 prune 恢复TODO模型选择以 live-mutation 形状回归host selectModel 三件套现成,其 client 消费方尚未构建)。