diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index e1b8afe3d3..e6ba37d8c5 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md 2026-08-10-remote-event-delivery.md: c0bc459eb5f96dccd135417c0d5d4d2f743aad5e -2026-08-10-remote-event-delivery.zh.md: 08e3570708c20223697a186ee79e16f5ac3bda8b +2026-08-10-remote-event-delivery.zh.md: bb02addbf92cd4db477ed5c6e9627469faedd89f diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 08e3570708..bb02addbf9 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 +[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)最初只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**需要一个不归 API Proxy 领域所有的投递机制。 -Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 +Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 等单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。若每条事件都要穿过 API Proxy 手写帧、Client Runtime 手写桥和 Client 事件别名才能抵达 UI,这些层不会陈述 owner 事件之外的新事实。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 @@ -18,13 +18,13 @@ Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference - `packages/api/remotes/src/remote-events.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 - wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。 -- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。 +- Host source 由 `api/remotes` 注册到 API Gateway;Gateway 在既有 `/api/remote.mux` 上保留内部 logical endpoint `$events`,不增加物理连接,也不让 API Proxy 解释事件。 - 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 - 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 Typert 自己。 -一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 `preset.ts`,让导出的 `types.ts` 专门承载 client-safe 事件声明。 +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。当前名单内各 owner 都从 client-safe 类型出口提供同一份事件声明。 -五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`。真正需要投影或去重的数据仍保留专用帧。 +名单内事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`;Session 与动态 Cordis 的无状态通知使用同一机制。真正需要 baseline、投影或去重的数据仍保留专用 Remote stream。 `skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但**没有任何已交付消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 @@ -56,15 +56,13 @@ $on(event: Event, listener: Events[Event]): () `Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 -**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行隐式的 `PLATFORM_MODULES` 加 `PRELOADED_CLIENT_EXTERNALS` 基座、包自身的 `dsh.client.external` 请求、`INLINE_SAFE` wire 层与 `/remote` 生成物值导入。靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: +**契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -```ts ignore-check -$dispatch(event: string, args: readonly unknown[]): void -``` +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 -持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。`event` 形参是 `string` 而非 `TypertRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 -投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。 +投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错或返回拒绝的 Promise 都就地隔离并记日志,不能拖垮事件投递或 Connection generation。 ### 名单:两个 face 共读的同一份声明 @@ -74,8 +72,19 @@ $dispatch(event: string, args: readonly unknown[]): void // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ 'agent-preset/selected', + 'api-session/activity', + 'api-session/added', + 'api-session/error', + 'api-session/removed', + 'api-session/status', 'commands/change', 'credentials/reference-updated', + 'cordis/request-run', + 'cordis/request-run-resolved', + 'cordis/dynamic-package', + 'cordis/dynamic-retract', + 'cordis/inspect-query', + 'cordis/inspect-query-resolved', 'llm/adapters-updated', 'settings/document-updated', ] as const @@ -100,20 +109,20 @@ API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] **「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。 -载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 +载荷 JSON-safe 交给运行时:`api/remotes` 的 Host source 在入队前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 -### 线协议(apiproxy) +### 线协议(API Gateway Remote mux) ```ts ignore-check -| { type: 'host/remote-event'; event: string; args: JsonValue[] } +{ type: 'ready' } +{ event: string; args: JsonValue[] } ``` -zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。 +Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -`events.host()` 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。 +Client 要求首项恰好是只含 `type: 'ready'` 的对象,后续每个 item 则恰好包含非空 `event` 与数组 `args` 两个字段。浏览器 wire 的 JSON 解码保证元素是 JSON 值;进程内载体则读取同一个已经过 `isJsonValue` 校验的 Host source。未知但结构合法的事件名会在没有订阅者时静默丢弃。 - -`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-input-trigger` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 +`$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 @@ -127,21 +136,23 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 | 位置 | 改动 | |---|---| -| `dsh-typert-protocol` | `src/types.ts` 加 `TypertForwardableEvent`、`TypertRemoteEventSelection`、`TypertRemoteEvent`;`TypertClientRemote` 增 `$on` 与 `$dispatch`。纯类型,零运行时 | -| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅按注册项寻址、`ctx.effect` 归属调用方 fiber)与 `$dispatch`(快照后按注册顺序派发,收容抛出或拒绝的 listener) | -| `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` | +| `dsh-typert-protocol` | `src/types.ts` 提供 `TypertForwardableEvent`、`TypertRemoteEventSelection` 与 `TypertRemoteEvent`;`TypertClientRemote` 只公开 `$on`。纯类型,零运行时 | +| `api/gateway` | Host 半提供唯一 Remote event source 注册位、`$events` logical stream 与 opening ready 项;Client 半把私有 pump 注册为 Connection generation source,负责 item 校验、按注册顺序派发以及 listener 异常收容 | +| `api/remotes` | `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位)双列进两个 face;Host 半注册每 Client 独立的 allowlist source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删除五个专用变体及其 zod;`events.host()` 按名单挂监听并通过 `assertJsonArgs` 校验 | -| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | -| `client/runtime` | 五条 Client 事件桥分支收敛为 `ctx.remote.$dispatch(frame.event, frame.args)`,并删除重复声明 | -| 5 个消费者 | ui-commands / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | -| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | +| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | +| `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | +| `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | +| 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 -**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。 +**继续寄生 API Proxy 的 Host downlink。**这样可以复用 Connection generation 和 `connection/reset`,但会让 API Proxy 保留 Remote 事件 allowlist、队列、schema 和 Client Runtime bridge,领域传输也无法随其他 Remote stream 共用生命周期。API Gateway 已有常驻 `/api/remote.mux` 后,`$events` 只增加一个 internal logical stream,不需要第三条 WebSocket,因此转移到 Gateway 的成本和所有权都更合理。 + +**给 Remote 事件另开第三条物理 WebSocket。**独立通道能拥有自己的连接状态,但会重复 Gateway mux 已经提供的认证升级、复用、取消、错误映射和退避重连。内部 `$events` endpoint 保留独立 logical stream,同时复用一条物理连接。 **在 type-meta 立一张独立的 `TypertRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。 @@ -157,21 +168,22 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 钉住该行为的东西: -- 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等。 +- Host source 真组合测试:两个 Client stream 各自收到 host emit 的 `{ event, args }`,其中一个断开不会影响另一个;非 JSON 实参会响亮拒绝且不会毒化后续合法事件。 - 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(`goal/changed`)、返回值非 `void` 的事件。`$on('slots/changed', …)`(client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 - 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。 - `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。 - 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。 -- `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。 -- 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。 +- Gateway 测试覆盖 source 缺失、重复注册、撤销中止、payload 拒绝、ready 先于事件,以及浏览器与进程内两种 carrier;Client 测试覆盖 generation source 注册边界、描述与增量就绪顺序、物理失败后重开、Host 错误与意外结束、非 ready 首项、畸形事件项和 dispose quiescence。 +- JSON 参数校验直接在 Host source 上覆盖:类型化的 `ctx.emit` 通常造不出畸形值,但 runtime allowlist 配置错误仍必须响亮失败。 +- `host/remote-event`、公开 `$dispatch`、Client Runtime bridge 和 API Proxy 的 allowlist 依赖都不存在;各消费方直接观察 owner 事件。 ## 后果 -- **寄居在 legacy 帧联合里**:契约住在 apiproxy 的 `HostFrame` 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 `api-remotes`,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。 +- **Gateway 有一个非生成 endpoint**:`$events` 不对应业务 namespace,也不进入 Typert descriptor;它是 Gateway 与 `api/remotes` 之间的内部连接点,同时定义 Client Connection generation 的存活期。严格的空 payload 校验、opening ready 校验和单 source 注册限制它不会演化成第二个手写业务 API。 - **两个文件打破了 api/remotes 的 face 互斥约定**:`src/remote-events.ts` 与 `src/types.ts` 同属两个工程,各自向共享的 `lib/types` 发射一份相同声明。内容逐字节相同、`.tsbuildinfo` 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(`paths` 指向源码面)。 -- **载体交接是开发者可见的**:任何持有 `ctx.remote` 的 client 插件都能调 `$dispatch` 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,`ctx.emit` 同样可达——与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 `$on` 的转换」,不假装该端口鉴别调用方。 -- **畸形实参在发射方的收容里失败,而非加载期**:`assertJsonArgs` 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。 +- **生产方保持私有**:业务插件只能调用 `$on`;Host source 注册和 Client 派发都不在 `TypertClientRemote` 上暴露,测试 double 以自己的 `emit` 方法驱动订阅,不伪装成生产接口。 +- **畸形实参在 emit 点失败**:`api/remotes` listener 在入队前抛出,因此调用 Host `ctx.emit` 的操作立即看到名单配置错误;队列仍可继续投递后续合法事件。 - **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。 -- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放——这些都是纯失效信号,且 `connection/reset` 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。 +- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;可应答交互与快照状态不应进入 `$on`。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypertForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml new file mode 100644 index 0000000000..cac10c657f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +2026-08-18-session-history-and-event-transport.md: 976189c815d1980790cc534ee7cdfc3da47e89fe +2026-08-18-session-history-and-event-transport.zh.md: 06987f34100096a53647510af6a2ac2d3c5cdfa4 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md new file mode 100644 index 0000000000..976189c815 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -0,0 +1,86 @@ +# Agent Note: Session history and event transport + +Status: implemented + +English | [中文](2026-08-18-session-history-and-event-transport.zh.md) + +## Problem + +The browser Session consumes two data categories with different lifecycles. A durable Session log and its projections must support cold reads while no Agent is attached; queue, approval, question, and jobs state is process-local and authoritative only while the Agent or corresponding wait still exists. The legacy API Proxy mixed both categories in one all-Session mux, where `session/subscribed`, history refetches, and several baselines jointly handled reconnects, so the interface could not reveal whether an observation was allowed to resume an Agent. + +Typert's generic `Agent` and `Session` lookups resume an ordinary cold Session. If history, projection, or state subscriptions use those parameters directly, opening a page can resume an Agent; if every operation instead remains cold, prompt, create, and fork cannot perform the activation they explicitly require. Activation policy must belong to each operation rather than arise implicitly from a carrier or parameter type. + +Removing the aggregate `session/event` path also creates a list-consistency problem: the old client updated activity ordering from every event it received, while a per-Session `follow` does not cover Sessions that are not open. The list must obtain the latest user-prompt time from a cold-readable domain projection instead of depending on whether one browser follows that log. + +## Decision + +`packages/api/session-controller` provides `@deepseek-ai/dsh-api-session-controller`. Its Host service mounts as `ctx.sessionController` and generates `ctx.remote.session`; its Client entry consumes unary and stream methods through the API Gateway's shared Remote WebSocket mux. One owner handles Session cold reads, live control, interaction responses, and explicit business commands, while internal agent, commands, control, history, and list controllers retain implementation-level separation. + +The API Gateway Client plugin opens `/api/remote.mux` as soon as it activates and keeps the physical WebSocket connected even with no logical streams. The mux recreates the physical connection with capped jittered backoff after an initial connection failure or an established connection loss; logical streams waiting to open share that reconnect loop, while an already-open generated stream terminates with `RemoteStreamCarrierError`. Gateway's `$stream` supervisor reopens only after that carrier failure: it permits one isolated retry against an available Host or waits for the next Host generation, while the Session consumer supplies the latest sequence for follow or requires a replacement baseline for control. Business and protocol failures remain terminal. Client disposal stops backoff, closes candidate and active sockets, and awaits the background loop. In-process `connection.rpc.open` continues to bypass the browser mux. + +### Activation policy + +Session Remote methods pass a `SessionId` or `SessionAddress` without triggering a generic Typert lookup through the parameter type. `SessionController` distinguishes cold inspection, live-only lookup, and resume-permitted resolution so every endpoint's activation behavior is visible and independently testable. The generic `Agent` and `Session` lookups it configures for other Remote namespaces reuse the same preset, concurrent-resume, and subagent-ownership policy. + +| Operation | Source or result without a live Agent | Activation rule | +|---|---|---| +| `session.page(address)` | Read the header and log from persistence | Never resumes an Agent | +| `session.follow(address)` | Inspect persistence, replay the missing suffix, then wait for future commits | Connecting and waiting never resume an Agent; events can appear only after another explicit command activates the Session | +| Projection and Session-list baseline | Recover from durable events or the projection cache | Never resumes an Agent; reading a title does not require an Agent | +| Queue, approval, question, jobs, and live projection in `session.control()` | Observe only attached Agents, pending registries, and process-local registries; absence means empty or unavailable | Subscription, reconnect, and baseline generation never resume an Agent | +| `session.respond`, `updateQueue`, and `cancel` | Reach only a pending item or live Agent that still exists; stale operations return an explicit failure | Never resumes an Agent for live state that has already disappeared | +| Session list, search, attachment, and fork-source reads | Inspect persistence or an attached Session | The read itself never resumes an Agent | +| Explicit Session commands such as prompt, rename, and model changes | Resolve or resume the target according to the command's own policy | Resumes only when the command contract explicitly permits it | +| Create and the fork target | Create a new Session and Agent | The explicit user command authorizes creation; reading the fork source remains cold | + +`follow` installs its `session/event` listener before inspecting an attached Session or persistence. It returns the cursor at open time; a reconnect carrying `afterSeq` first replays the missing suffix from the authoritative log, then drains commits buffered during the read in sequence order. A cold Session can therefore open history and follow immediately and remain waiting without attaching an Agent. A physical WebSocket loss resumes from the last applied sequence; Host business and persistence failures arrive as terminal Remote Stream errors and publish as the Session's `openError`, rather than being misclassified as an indefinitely retryable carrier loss. + +### Live control stream + +`control()` is one Host-wide shared Remote stream that preserves the value of aggregate observation: a browser receives interaction and transient state for every currently live Session without activating those Sessions by opening their transcripts. The Host installs queue, pending-interaction, jobs, projection, and Agent-lifecycle listeners before producing a complete baseline, then drains changes buffered during baseline construction. Every physical reconnect replaces the Client's transient mirror with a new baseline instead of inventing durable sequences for process-local values. + +Queue and jobs use complete snapshots with last-wins application. Agent attach, detach, and owner disposal produce a baseline or empty snapshot capable of clearing stale values. Approval and question control frames carry a stable `interactionId`; the opening baseline replays requests that remain pending, resolved frames withdraw requests, and the `respond` Remote unary uses the same identity with the existing outcome or answer semantics. The mechanism preserves first-responder-wins and explicit stale-response failure without the old `RpcRequest` envelope. + +The projection baseline still accompanies the tail `page` log cut. `control()` pushes only later complete projection values with their watermarks, and the Client merges both sources by retaining the higher sequence. A cold title and other log-derived projections recover through `page` or list reads; subscribing to live projections never starts an Agent to obtain a value. The opened cursor from `follow` replaces `session/subscribed` for the durable log, while the control baseline replaces its responsibility for clearing queue, jobs, and pending-interaction mirrors. The legacy `session/event`, `session/subscribed`, and aggregate event mux consequently have no remaining responsibility. + +Session added and removed notifications and Agent running status can recover from a Session-list baseline, while an Agent error without a turn position is an immediate notification that needs neither a response nor replay. These do not enter the stateful control stream; `@deepseek-ai/dsh-api-session-controller` exposes them as client-safe events under the [`ctx.remote.$on`](2026-08-10-remote-event-delivery.md) delivery rules. Observing these events also never resumes an Agent. + +### Unified Session Controller ownership + +`SessionController` owns the Session BFF formerly housed in API Proxy: list, search, create, models, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, control, and respond. It owns preset-aware creation and resumption, the subagent ownership fence, Workspace association, model selection, history reads, and endpoint-specific error projection. Remaining API Proxy domains reuse this identity policy through `ctx.sessionController.inspect()` and `resolveAgent()` instead of retaining a second resolver. + +The service selects cold inspection, live-only `ctx.agents.get`, or explicit ensure/resume per endpoint. Queue mutation, cancel, and interaction response can operate only on authoritative objects in the current process even when the user initiates the command; prompt, rename, and model changes explicitly resume according to their own contracts. Internal controllers keep data-channel and command implementations separate, while public ownership and activation policy have one home. + +Session create and fork may still call the Workspace registry to establish ownership, while Workspace Remote methods and `host/workspace-*` notifications remain in API Proxy. Workspace migration is not a prerequisite for completing the Session data channel. + +### List timing and projections + +`@deepseek-ai/dsh-api-session-controller` owns the `sessionListMetadata` projection and the list projection built from it. The state changes `blank` to false at the first `turn/start` and records `lastPromptAt` for each `user/message` whose source is the user; a Session row's `updatedAt` is always `max(header.createdAt, lastPromptAt)`. A cold list recovers this value from the projection cache or durable log, and a live change updates the list through a client-safe `$on` notification, so a Session whose transcript is closed still moves after a new prompt. + +`updatedAt` is a derived field of the API Session list. It is neither written to the Session header nor borrowed from the Workspace's own `updatedAt`; Workspace ordering and update times remain owned by the Workspace registry. + +## Alternatives considered + +**Resume an Agent whenever any Session stream opens.** Viewing history, reading a title, reconnecting a tab, or observing background state would then have execution side effects, and several browsers could trigger redundant resumes. Cold logs and recoverable projections already have persistence sources, so observation has no authority to activate execution. + +**Allow `follow` only for a live Agent.** This would force the transcript's first screen to resume an Agent or return to the race between unary history and a separate live stream. Subscribing by identity before a cold read covers both history and events from later explicit activation without activating the Agent itself. + +**Publish separate `session-transport` and `api/session` packages.** The data channel and command API are conceptually distinct, but both depend on Session addresses, Agent activation policy, interaction responses, and Client mount order. Splitting them would create cross-package coordination without independently replaceable capabilities. One `SessionController` provides unified public ownership while internal controllers preserve implementation separation and each endpoint declares whether activation is permitted. + +**Convert queue, approval, question, jobs, and projection entirely to ordinary `$on` events.** Ordinary events provide no reconnect baseline and cannot express a stable response identity for pending interactions; one lost push would leave state permanently stale. The shared control stream establishes one complete baseline for stateful live data, while lifecycle notifications recoverable by query continue to use `$on`. + +**Retain the API Proxy mux.** This avoids migrating existing frames but preserves a hand-written union, schema, response envelope, and second stream lifecycle, preventing API Proxy from leaving the Session data plane. + +**Keep deriving list activity from aggregate `session/event` delivery.** List correctness would depend on which Sessions a browser happens to consume and would treat arbitrary plugin events as user activity. `sessionListMetadata.lastPromptAt` directly represents the ordering fact the product needs and can be recovered from cold durable state. + +## Verification + +Host tests pin that cold `page` and cold `follow` do not add an attached Agent, a cold follow receives contiguous events after an explicit prompt resumes the Session, reconnect replays only missing sequences, and persistence or business failures retain their category and message as terminal errors. Control tests pin listener-before-baseline ordering, no cold-Session resumption, attach and detach cleanup, complete queue and jobs snapshots, stable pending-interaction identities with first-responder-wins, and higher-sequence projection watermarks winning. + +Session Controller tests separately pin cold reads, live-only commands, and explicit-resume commands, proving they do not share one implicit activation policy; create and fork cover presets, ownership, and Workspace association. List tests cover one `lastPromptAt → updatedAt` calculation for attached and cold Sessions and prove that a prompt reorders a Session whose transcript is closed. Client tests cover independent follow and control cancellation, replacement of transient mirrors after a control reconnect, and the absence of legacy mux frames from the Session data flow. + +## Consequences + +The browser can read and follow a durable Session while its Agent is stopped. Observation never implicitly resumes execution; only explicit Session commands activate or create an Agent according to their own contracts. Durable logs repair missing suffixes by sequence, while process-local control state converges from a complete baseline, so the two reconnect strategies no longer imitate each other. + +This decision takes ownership of the Session lifecycle, transcript, input control, and stateful streams deferred by [unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md), and replaces that proposal's direct delegation of `session.rename` to the title service with one `api/session-controller` owner; its other business migrations remain independent. It replaces only the API Proxy carrier from [web background-job display](../feature/2026-08-08-web-background-job-display.md), retaining complete job snapshots, process-local lifecycles, and the rule that observation never resumes an Agent. Workspace remains an explicitly deferred boundary. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md new file mode 100644 index 0000000000..02cd147472 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -0,0 +1,365 @@ +# Agent Note: 会话历史、控制状态与 Remote 事件传输 + +Status: implemented + +[English](2026-08-18-session-history-and-event-transport.md) | 中文 + +## 问题 + +浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。 + +这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 + +观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。 + +prompt、create、fork、模型选择等命令又确实需要按各自语义创建或恢复 Agent。激活权限必须属于具体 Remote 方法,而不能由 carrier、参数类型或共享 lookup 暗中决定。 + +旧 API Proxy 的全 Session mux、`HostFrame` 与 Workspace 通知把领域数据、baseline、错误和连接生命周期编码进同一手写协议。每增加一种状态都要复制帧定义、Client bridge、重连和清理逻辑,API Proxy 也无法退回只承接尚未迁移的业务方法。 + +Host 向 Client 的 Cordis 事件还有两种调用语义。普通通知只需要广播;Approval 与 Question 一类 Agent-scoped waterfall 必须允许 Client claim、调用 `next()` 委托、返回结果或拒绝,并在多 Client、断线和取消下保持一次 Host 调用的身份。 + +这些需求需要一个通用 transport 生命周期,但不能让 Gateway 理解 Session、Workspace、Approval 或 Question 的业务数据。 + +## 决定 + +API Gateway 拥有 Remote transport、stream 生命周期和 Remote Event 协调;Session Controller 与 Workspace Controller 拥有各自的 Host API、wire 类型和 Client 领域 adapter;Client Runtime 只装配并消费这些对象,不再实现另一套 carrier 状态机。 + +当前所有权如下: + +```text +[client/connection] +|-- Host description +|-- Connection generation +`-- unary RPC transport + +[api/gateway/client] +|-- RemoteStream +|-- RemoteSnapshotStream +|-- RemoteJournalStream +`-- ctx.remote.$on + $events pump + +[api/session-controller] +|-- ctx.remote.session unary commands +|-- session.control snapshot stream +|-- session.page + session.follow journal +`-- Session Client adapters + +[api/workspace-controller] +|-- ctx.remote.workspace unary commands +|-- workspace.follow snapshot stream +`-- Workspace Client model and adapter + +[api/remotes] +`-- application Remote Event allowlist and Host Cordis source + +[client/runtime] +`-- compose Session and Workspace domain state for consumers +``` + +API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 下行事件 carrier。`/api/events.host`、`HostFrame`、`stream/error`、`ServerRequest` 及其 WebSocket/SSE 分支不参与这条数据链路。 + +### Connection generation 与物理连接 + +浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 + +首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 + +进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 + +Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 + +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 + +`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 + +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 + +Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 + +插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 + +### 通用 Remote stream 模型 + +Gateway Client 提供三个不依赖 React、只允许一个 consumer 的生命周期对象: + +```text +RemoteStream +|-- RemoteSnapshotStream +`-- RemoteJournalStream +``` + +领域 Controller 通过组合或薄 adapter 使用它们;Session 与 Workspace 不继承一个知道领域帧的共同 Controller 基类。 + +#### `RemoteStream` + +`ctx.remote.$stream(options)` 返回 `RemoteStream`,负责一个 logical stream 跨物理 generation 的重开、取消和 dispose。 + +每个 item 携带单调 generation、该 generation 的 `AbortSignal` 与 `accept()`。领域 consumer 只有在验证 opening cursor 或 baseline 后才调用 `accept()`。 + +只有 `RemoteStreamCarrierError` 可触发重试。Host 仍可用时允许一次独立重开;否则等待新的 Connection generation。业务错误、协议错误和 opening 失败直接终止。 + +`restart()` 只淘汰当前物理 generation,保留 logical stream;`dispose()` 永久结束 logical stream、pending retry 与 iterator,并等待 quiescence。 + +`RemoteStream` 不理解 baseline、delta、page、cursor、seq 或任何领域 frame。 + +#### `RemoteSnapshotStream` + +`RemoteSnapshotStream` 要求每个 generation 恰好以一份完整 snapshot 开始,之后只能出现 delta。 + +update 早于 snapshot 或同 generation 出现第二份 snapshot 都是 terminal protocol error。 + +snapshot 成功应用后才接受该 generation。carrier 重连期间保留上一份已发布状态,新 generation 的 snapshot 一次性替换旧镜像。 + +领域 adapter 提供 frame 判别、snapshot replacement、delta reducer、carrier 状态和 terminal failure sink;通用层不解析 Session 或 Workspace 字段。 + +Session control 与 Workspace state 各使用一个独立的 `RemoteSnapshotStream`。 + +#### `RemoteJournalStream` + +`RemoteJournalStream` 组合一个 live follow 与同 namespace 的 page 方法,适用于有稳定顺序、可分页历史和 live tail 的 append-only journal。 + +首次打开先建立 follow 并取得 opening cursor,再读取 initial page。page 请求期间产生的 live entries 已进入 follow 队列,因此不会落在“先读历史、后订阅”的竞态窗口中。 + +通用层按 cursor 去除 page 与 queued entries 的重叠,验证连续性,并在 page 覆盖 opening cursor 后发布一份完整 window。 + +连续 live entry 发布 `append`,更早的历史页发布 `prepend`。重连、cursor 跳跃或无法证明连续性时触发 tail page repair。 + +repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼成连续窗口后只发布一次 `replace`,不会把半修复状态暴露给消费者。 + +`RemoteJournalStream` 拥有 opening cursor、resume cursor、分页、重连 catch-up、重叠去重和 gap repair。领域 Session 对象不复制这些状态机。 + +### Session Controller + +`packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 + +它拥有 Session list、search、create、models、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow、control 与 respond。 + +包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 + +其他 Host Remote namespace 通过 `ctx.sessionController.inspect()` 或 `resolveAgent()` 复用同一身份规则,不保留第二份 Session resolver。 + +#### 激活策略 + +Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类型触发通用 Typert Session lookup。 + +每个方法显式选择冷检查、live-only 查找或允许 resume 的解析方式: + +| 操作 | 无 live Agent 时的数据来源或结果 | 激活规则 | +|---|---|---| +| `session.list`、`search` | persistence、投影缓存或冷日志 | 永不恢复 Agent | +| `session.page(address)` | attached Session 或 persistence 日志 | 永不恢复 Agent | +| `session.follow(address)` | 冷读当前 cursor,等待将来的 append | 建联和等待都不恢复 Agent | +| `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | +| `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | +| `session.updateQueue`、`cancel`、`respond` | 仅命中当前 live 或 pending 对象 | 不为已消失状态恢复 Agent | +| `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | +| `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 | + +读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。 + +#### Session 日志 + +`session.page` 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 `throughSeq`;该值来自对应 `session.follow` generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 `beforeSeq` 的 tail page 必须精确结束于 `throughSeq`,其中 `-1` 表示空日志;`beforeSeq` 只选择该切点之前的更早页面,不能替代同步 cursor。`maxMessages` 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。 + +tail page 同时携带不晚于 `throughSeq` 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。 + +普通 Session 与 direct subagent 使用同一个 `SessionAddress` 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。 + +`session.follow` 在检查 attached Session 或 persistence 前先安装 `session/event` 与 `session/created` listener,再读取当前 cursor。 + +首次 follow 返回 `{ type: 'opened', cursor }`。带 `afterSeq` 的 generation 先从权威日志重放缺失后缀,再按 seq 排出读取期间缓存的 commit。 + +冷 Session 可以立即打开历史并保持 follow 等待。只有另一条显式命令恢复 Agent 后,后续事件才会出现。 + +Client 的 `SessionEventStream` 继承 `RemoteJournalStream`,只提供 `session.follow`、`session.page`、Session seq 算法与 repair request。通用层先取得 opening cursor `C`,再调用 `session.page({ throughSeq: C })`;读取期间收到的 `C + 1...` entries 留在 follow 队列中,page 精确覆盖至 `C` 后才按连续 seq 合并并发布。 + +```text +ctx.remote.session.follow(address, afterSeq?) --------| + |[]> SessionEventStream +ctx.remote.session.page(address, throughSeq, pageArgs) -| |-- replace(window) + |-- prepend(history) + `-- append(live entry) +``` + +每个 Client Session 只持有一个当前 `events: SessionEventStream | undefined`。只读 `SessionEventSource` 把已物化 event window 交给 Conversation consumer。 + +Session 的 `openGeneration` 只阻止被 resync、地址替换或 dispose 淘汰的异步结果写回;它不参与 transport retry。 + +initial page、repair page 或 follow 的 terminal failure 进入当前 Session 的 `openError`。旧业务 epoch 或旧 stream 的失败不能覆盖新状态。 + +#### Session live control + +`session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。 + +每个 generation 先发完整 baseline,再发 queue、jobs、projection、approval 与 question 的增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。 + +queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。 + +pending approval 与 question 使用稳定 `interactionId`。opening baseline 包含仍待处理的请求,resolved 帧撤销请求,`session.respond` 使用同一 id,保留首个有效应答者获胜与过期应答明确失败的语义。 + +原始 `approval/request` 与 `user-questions/request` 同时是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener,因此 control provider 仍能提供可重连的 pending 镜像。 + +projection baseline 与 tail page 的日志切点独立产生,Client 总是保留较高 seq 的值。订阅 live projection 不会为取得值而启动 Agent。 + +Session added、removed、activity、running status 与无 turn 位置的 Agent error 不进入 stateful control stream;它们是可由列表 baseline 修复或无需重放的 `ctx.remote.$on` 通知。 + +Session 列表的 `updatedAt` 取 `max(header.createdAt, sessionListMetadata.lastPromptAt)`。`lastPromptAt` 只由用户来源的 `user/message` 更新,可从冷 projection 恢复,不依赖浏览器是否正在跟随该 Session。 + +### Workspace Controller + +`packages/api/workspace-controller` 提供 Host `ctx.workspaceController` 与生成的 `ctx.remote.workspace` namespace。 + +它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession 与 `follow`。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。 + +`WorkspaceFeed` 同步观察 storage `domain/changed`,并为每个 follow generation 先发送完整 baseline,再发送 `upsert`、`remove`、`order` 与 `archived` 增量。 + +完整 `order` frame 是 Workspace 排序的权威值。它避免 Client 根据 upsert 到达顺序猜测展示顺序,也能在重连 baseline 后收敛。 + +`createWorkspaceStateStream()` 把 `workspace.follow` 装配为 `RemoteSnapshotStream`。Client Runtime 只负责启动和持有该 stream。 + +`ClientWorkspaceModel` 位于 Workspace Controller 的 Client 面,拥有 baseline/increment 解析、已物化列表、归档集合、命令结果回显及 unary 与 stream 到达竞态的合并规则。 + +成功的 unary 命令可以立即更新本地模型;后到的 stream commit 仍以 Host projection 与完整 order 校正状态。已删除 Workspace 的 id 被记录,延迟结果不能把它重新插回列表。 + +```text +ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream + |-- replace(baseline) + |-- upsert/remove(view) + |-- replace(order) + `-- replace(archived ids) +``` + +Workspace Remote 方法、状态 feed 和 Client 数据模型均不经过 API Proxy,也不依赖 `host/workspace-*` 通知。 + +### Remote Event + +Remote Event 复用 owner 包的 Cordis `Events` 声明。Host 原事件是唯一业务签名,Client `ctx.remote.$on(event, listener)` 从同一声明推导参数、waterfall 结果与 `next()`。 + +`packages/api/remotes` 的 allowlist 是应用选择的唯一来源。每项显式标注 `emit` 或 `waterfall`,该 mode 同时决定 Host 监听方式、Client 合法键集和 wire frame 类型。 + +系统不声明 `RemoteInvocationMap`,不要求 Client 再写一份 `@Remote`,也不以最后一个运行时参数是否为函数来猜测调用模式。 + +Remote Event 下行帧是显式 discriminated union: + +```text +ready { type, clientId } +emit { type, event, args } +waterfall { type, event, eventId, agentId, request } +cancel { type, eventId } +``` + +WebSocket JSON 与进程内 carrier 的入口都从 `unknown` 开始按 `type` 和精确字段验证;验证完成后的分发只接收 typed union。TypeScript 静态类型不替代 wire 校验。 + +普通 `emit` 参数必须是无损 JSON。Client 在每个 Remote 实例私有的 Cordis key 上调用 `parallel()`,保留注册顺序、调用方 fiber 所有权和 listener 错误隔离。 + +私有 key 防止 Host 事件与 Client 本地同名 Cordis 事件互相触发。Client Remote 不维护自己的 subscription registry 或手写 listener chain。 + +可返回的 waterfall 当前只支持 Agent scope。事件签名必须是一个含直接 `agent` 字段的 request,加一个返回同类型结果的 `next()`,整体返回 Promise。 + +Host 只投影 request 一级的 `agent` 与 `signal`:`agent` 变为 frame 的一级 `agentId`,`signal` 成为 delivery lifetime,其余字段必须整体为无损 JSON。 + +Client 用 `agentId` 同步解析已存在的 Agent Context,把当前 delivery signal 放回 request 的直接 `signal` 字段,再在目标 Context 的私有 key 上调用 Cordis `waterfall()`。 + +系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clone/restore Context 和 AbortSignal,也不等待未来出现的 Agent Context。 + +Client adapter 未注册、Agent Context 不存在或已经释放时,本 Client 立即返回 `next`。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。 + +Gateway Host 为每个未完成 waterfall 保存 `eventId`、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。 + +每个 generation 的队列保证一次投递,因此 Client 不保存 `seen` 集合。`clientId + eventId` 绑定结果与当前 generation,旧连接的回包不能完成新连接上的 delivery。 + +多 Client 同时接收 waterfall 时,第一个 result 或 rejection 完成 Host 调用,并向其余 Client 发送 `cancel`。只有所有已投递 Client 都返回 `next` 时,Gateway 才继续原 Cordis chain。 + +Host caller signal 取消、Agent Context 释放、Client generation 结束和 losing-client cancellation 都会终止对应的等待。 + +Client 通过现有 HTTP unary RPC `$events/result` 回送 `next`、result 或 rejection;下行事件仍复用 Remote WebSocket mux,不为应答建立 duplex WebSocket。 + +`$events/result` 失败会令当前 Connection generation 失败。Host 随 generation 撤销该 Client 的 delivery,pending event 在下一 generation 重放,Client 不维护第二套结果重试队列。 + +普通 `$on` 通知在断线后不重放。凡正确性依赖恢复的数据必须有 query、cursor 或 opening baseline,不能依赖 Remote Event 恰好送达。 + +Client listener 晚于事件到达才注册时不补送;HMR 也没有专用补投语义。 + +### API Proxy 的剩余边界 + +Session Controller 与 Workspace Controller 直接提供生成 Remote namespace;API Remotes 与 API Gateway 直接提供 Host-to-Client 事件。 + +Client Connection 只维护 Host generation、description 与通用 RPC,不解析领域 frame。 + +Client Runtime 只接收 Controller adapter 产出的领域变更,不识别 `HostFrame`、`session/subscribed`、`session/event` mux frame 或 `host/workspace-*` frame。 + +API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace、Remote Event 或 Connection generation 的依赖。 + +## 备选方案 + +**建立任意 Session stream 时自动恢复 Agent。** 这会让查看历史、读取 title、重连标签页或观察后台状态产生执行副作用,也会让多个浏览器触发重复恢复;冷日志和投影已有 persistence 来源。 + +**只允许 live Agent 使用 `session.follow`。** 这会迫使 transcript 首屏恢复 Agent,或重新引入 unary history 与 live subscription 之间的竞态;按 identity 先 follow 再冷读能同时覆盖历史和未来的显式激活。 + +**把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。 + +**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复或可由独立查询修复的通知适合 `$on`。 + +**让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。 + +**给 Remote Event 新建一份 Client invocation 声明。** 第二张 map 或 Client `@Remote` 会复制 owner Cordis 事件签名并形成漂移点;从同一 `Events` 声明推导 `$on` listener 和结果类型可以构造性地保持一致。 + +**把 Agent scope 做成任意深度对象投影。** 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 `agent` 与 `signal` 足以覆盖当前 waterfall。 + +**等待 Client Agent Context 或 adapter 后再分发。** registry waiter、竞态复查和临时 delivery Fiber 会为一个可直接委托的 Client 增加额外生命周期;目标不存在时立即 `next` 保持 Cordis waterfall 语义。 + +**给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 + +**继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 + +**从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 + +## 验证 + +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 + +Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 + +`RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 + +`RemoteSnapshotStream` 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。 + +`RemoteJournalStream` 测试固定 follow-before-page、opening overlap 去重、连续 append、历史 prepend、重连 catch-up、gap repair 与一次性 replacement。 + +Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 + +Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement、projection watermark,以及 pending interaction 的稳定 id 与首个应答者获胜。 + +Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。 + +Workspace Host 测试固定 baseline-first、upsert/remove、权威 order、archived set 和 follower disposal。 + +Workspace Client 测试固定 snapshot replacement、unary/stream 竞态、删除不复活、稳定排序和 terminal failure。 + +Remote Event 类型测试拒绝未选择事件、非 void 的 unscoped 事件、非 Agent-scoped waterfall 和签名不匹配的 mode。 + +Remote Event Host 测试固定 listener-before-ready、payload 校验、pending replay、多 Client first-result、all-next delegation、rejection、Host cancellation、Context release 和 losing-client cancel。 + +Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent Context 解析、`next`、result、rejection、cancel、旧 generation 回包拒绝和 `$events/result` 失败导致 generation 结束。 + +缺失 source、重复 source、撤回 source、非 ready 首项、未知 discriminant、额外字段与非 JSON 值都在各自 wire 入口响亮失败。 + +静态检查固定 API Proxy 不再导出 Session/Workspace Host frame carrier,Client Runtime 不再包含对应 bridge。 + +## 后果 + +浏览器可以在 Agent 停止时读取并跟随持久 Session。观察不隐式恢复执行,只有明确获得授权的 Session 命令按各自约定创建或恢复 Agent。 + +持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。 + +Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 + +Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 + +通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 + +Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host chain、断线重放 pending 和端到端取消;代价是当前协议只支持一级 Agent scope 与无损 JSON 请求/结果。 + +本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。 + +本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml index 31468d0ea3..f5724092a0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-result-card.md -2026-07-30-web-result-card.md: 591471d219295019d28b9eeaf2e57f6d2115d380 -2026-07-30-web-result-card.zh.md: c850c148ba32ca782fd1d6d7e5c3bbfa758391c5 +2026-07-30-web-result-card.md: 35ad06998136cbffcf4a049cb0c68adb97498b68 +2026-07-30-web-result-card.zh.md: 81656b5f659996d8a30ee293ccbbf562c7e6dd85 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md index 591471d219..35ad069981 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.md @@ -22,7 +22,7 @@ Neither result view carries a `content` copy. A UI that does not render the stru ## Consequences -The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. `apiproxy`'s session schema already accepts any `card` string (`packages/host/apiproxy/src/api/sessions.schema.ts`), so the new view crosses the wire without a schema change. +The frontend consumer is owned by the [web result card frontend note](2026-07-30-web-result-card-frontend.md): this producer change adds the contract arm and makes the two tools emit it, with no client-side rendering. Its one observable change is that the `web_search`/`web_fetch` `tool/result` events persist a `data.meta` payload (the `web-fetch` keyless snapshot was refreshed accordingly); model-facing render text and generic fallback content stay unchanged. The assembled-application transcript snapshot that exercises a `web` card belongs to the consumer change that renders it. Any `ToolResultView` consumer that switches exhaustively must add a `web` arm; a non-exhaustive consumer may use the raw-result fallback. Session Controller carries the event's typed `surfaceOp` without redeclaring card tags ([wire type](../../../../packages/api/session-controller/src/types.ts)), so the new view crosses the wire without a schema change. A future web tool that wants this card declares `presentResult` returning a `card: 'web'` view with its own `kind`; adding a third `kind` is a union edit plus the frontend's branch, not a new card tag. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md index c850c148ba..81656b5f65 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card.zh.md @@ -22,7 +22,7 @@ Status: implemented ## Consequences -前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。`apiproxy` 的会话 schema 已接受任意 `card` 字符串(`packages/host/apiproxy/src/api/sessions.schema.ts`),因此新视图无需 schema 变更即可跨 wire。 +前端消费方属于 [Web result card 前端 note](2026-07-30-web-result-card-frontend.zh.md) 的工作范围:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `web_search`/`web_fetch` 的 `tool/result` 事件持久化一个 `data.meta` 载荷(`web-fetch` keyless 快照当时随之刷新);面向模型的 render 文本与 generic 回退内容保持不变。渲染 `web` 卡片的组装应用 transcript(文本记录)快照属于渲染它的消费方变更。任何做穷尽 switch 的 `ToolResultView` 消费方都必须新增一个 `web` 分支;非穷尽消费方可以使用原始结果回退。Session Controller 直接携带事件中已类型化的 `surfaceOp`,不重新声明 card 标签([线路类型](../../../../packages/api/session-controller/src/types.ts)),因此新视图无需 schema 变更即可跨 wire。 未来想用此卡片的 web 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 32c4650128..2ae59b9806 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 1af949a537103637e8bac84b5bfa0f915fcf82e0 -2026-08-08-web-background-job-display.zh.md: 0aa8d65b7697312f603dd9ddd37f182da0b7f39c +2026-08-08-web-background-job-display.md: 962d29e35ffe436c5ab91307d0cdfa557bbd539f +2026-08-08-web-background-job-display.zh.md: 8b391c272b8ff38028cc6819f2fbec49505d85aa diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 1af949a537..962d29e35f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -14,24 +14,24 @@ The session header was already the place where per-session background activity l ## Decision -Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. +Task state reaches the browser as **one whole-snapshot control frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. ### Wire shape -One frame in the mux stream: +One frame in the Session Controller control stream: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts), alongside the other domain contracts, with its wire schema beside it in `jobs.schema.ts`: +`SessionJob` is browser-safe and owned beside the other Session Remote contracts in [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts): ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { Three `JobSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). -The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. +The frame carries a whole snapshot rather than a delta so start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. ### The task-registry change feed @@ -66,16 +66,16 @@ The listener is owner-granular rather than task-granular. The only consumer push Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. -### The api-proxy carrier +### The Session Controller carrier -`mux()` subscribes `ctx.jobs.onJobsChanged` and pushes `session/jobs`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) emits one complete Host-wide baseline before later `jobs` replacement frames. Every physical reconnect opens a new generation, so the client replaces its process-local mirror before applying further changes. Four rules the carrier keeps: -- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. -- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. -- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. -- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. +- **Never resume.** A change push reads `jobs.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.jobs.list(ctx.agents.get(session.id))`, where a Session with no live Agent correctly yields only unowned tasks. Neither path calls the [Session Controller Agent resolver](../../../../packages/api/session-controller/src/agent.ts), because listing must never revive a Session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every attached Session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('jobs')`. A composition without the registry reports empty job sets, and the client renders no entry point. +- **Represent emptiness explicitly.** The opening baseline contains an entry for every attached Session, including `[]`; a later change that empties one list also pushes `[]`. The client may then normalize an empty set to an absent key without retaining stale rows. ### The client mirror @@ -83,7 +83,7 @@ Four rules the carrier keeps: It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. -Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. +Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering. ### The header action @@ -117,7 +117,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0aa8d65b76..8b391c272b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -14,24 +14,24 @@ Status: implemented ## 决策 -任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 +任务状态以**每会话一帧的整份 control 快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 ### 线路形状 -mux 流中的一帧: +Session Controller control 流中的一帧: ```ts ignore-check -| { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] } +| { type: 'jobs'; sessionId: SessionId; jobs: SessionJob[] } ``` -`JobView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/jobs.ts`](../../../../packages/host/apiproxy/src/api/jobs.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `jobs.schema.ts`: +`SessionJob` 是浏览器安全类型,与其他 Session Remote 约定一起由 [`packages/api/session-controller/src/types.ts`](../../../../packages/api/session-controller/src/types.ts) 拥有: ```ts import type { JobId } from '@deepseek-ai/dsh-jobs/brand' -export interface JobView { +export interface SessionJob { id: JobId kind: string label: string @@ -48,7 +48,7 @@ export interface JobView { `JobSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 -这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 +这一帧带整份快照而非增量,因此启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 ### 任务注册表变更订阅 @@ -66,16 +66,16 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 -### api-proxy 载体 +### Session Controller 载体 -`mux()` 订阅 `ctx.jobs.onJobsChanged` 并推送 `session/jobs`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 +[`SessionControlController.control()`](../../../../packages/api/session-controller/src/control.ts) 先发出一份完整的 Host 范围 baseline,再发送后续 `jobs` 替换帧。每次物理重连都会打开新一代流,因此客户端会先替换进程本地镜像,再应用后续变更。 载体守着四条规则: -- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 -- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 -- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 -- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `jobs.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.jobs.list(ctx.agents.get(session.id))`,没有 live Agent 的 Session 正确地只得到无主任务。两条路径都不调用 [Session Controller Agent 解析器](../../../../packages/api/session-controller/src/agent.ts),因为列出任务绝不能复活用户随手划过的 Session。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已挂接 Session 推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('jobs')`。没有挂注册表的组合报告空任务集,客户端也就不渲染入口。 +- **显式表示空集。** opening baseline 为每个已挂接 Session 提供一项,包括 `[]`;后续变更清空一个列表时也会推送 `[]`。客户端因此可以把空集归一化为缺失键,而不会保留陈旧行。 ### 客户端镜像 @@ -83,7 +83,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void 它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 -两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 +两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。 ### header 入口 @@ -117,7 +117,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-jobs`](../../../../packages/host/apiproxy/tests/api-proxy-jobs.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index b73730d7e5..b21395a63a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 16185120530cc6fca607aed5c66502c1122e18fb -capability-seams.zh.md: d6c41023887c66c87cfd4fb65b007732b8b2e330 +capability-seams.md: b1faa5d4dce37eb338921c7117d451aae9ad252e +capability-seams.zh.md: 52c63e1491f184e7578b2eb6647fa5ff63a9e60b diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1618512053..b1faa5d4dc 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -35,6 +35,9 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_api_session_controller["api-session-controller"] + svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_apiproxy["apiproxy"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -51,7 +54,6 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] - pkg_apiproxy["apiproxy"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -215,6 +217,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway + pkg_api_session_controller --> svc_sessionController pkg_apiproxy --> svc_apiProxy pkg_approval --> svc_approval pkg_attachment --> svc_attachments @@ -359,6 +362,7 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash + svc_sessionController --> pkg_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -444,6 +448,7 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index d6c4102388..52c63e1491 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -37,6 +37,9 @@ flowchart LR pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] + pkg_api_session_controller["api-session-controller"] + svc_sessionController["ctx.sessionController
Host Session Remote controller"] + pkg_apiproxy["apiproxy"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -53,7 +56,6 @@ flowchart LR pkg_settings["settings"] svc_settings["ctx.settings
User-settings seam"] pkg_settings_file["settings-file"] - pkg_apiproxy["apiproxy"] pkg_credentials["credentials"] svc_credentials["ctx.credentials
Credential seam"] pkg_credentials_local["credentials-local"] @@ -217,6 +219,7 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway + pkg_api_session_controller --> svc_sessionController pkg_apiproxy --> svc_apiProxy pkg_approval --> svc_approval pkg_attachment --> svc_attachments @@ -361,6 +364,7 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash + svc_sessionController --> pkg_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -446,6 +450,7 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index de1bcba65f..00d01a8104 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9a1eda647e8643543d4ab23c877267ad06c445f5 -config-catalog.zh.md: ac0b8255a71c62770441c77b890eade0e051d561 +config-catalog.md: 31f73a905afb1fb94274b309f77b4ba41b697166 +config-catalog.zh.md: f09888603a9e77db1ea6ebf2a73eb2918b5bc62f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9a1eda647e..31f73a905a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -265,6 +265,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-session-controller` + +Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `userQuestions` · `workspaceRegistry` + +```ts config-catalog +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size read to determine blankness. */ + readonly coldBlankProbeMaxBytes?: number +} +``` + +Source: [`packages/api/session-controller/src/index.ts:60`](../packages/api/session-controller/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -365,7 +381,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:53`](../packages/client/connection/src/index.ts) @@ -742,7 +758,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -761,12 +777,6 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } ``` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ac0b8255a7..f09888603a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -267,6 +267,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-session-controller` + +需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `userQuestions` · `workspaceRegistry` + +```ts config-catalog +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size read to determine blankness. */ + readonly coldBlankProbeMaxBytes?: number +} +``` + +来源:[`packages/api/session-controller/src/index.ts:60`](../packages/api/session-controller/src/index.ts) + ## `@deepseek-ai/dsh-attachment-local` @@ -367,7 +383,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:53`](../packages/client/connection/src/index.ts) @@ -744,7 +760,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userQuestions` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `sessionController` · `workspaceRegistry` ```ts config-catalog /** Gateway plugin configuration. */ @@ -763,16 +779,10 @@ export interface Config { * @default 6 */ sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - /** - * Maximum physical size of a cold Session artifact eligible for blankness - * verification. Zero disables probes. - * @default 1024 - */ - coldBlankProbeMaxBytes?: number } ``` -来源:[`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) +来源:[`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 03991ac2d1..61ab54ba12 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: ccd4b0305c6253f7a00930ca0f0f2e5ffd5195b9 -session-projection.zh.md: 6b62f8898dfae7dc1d382e2c5466c5e8c3e725e9 +session-projection.md: 8614cf3466eff8deb360a7667ff6b4e37da1bf6e +session-projection.zh.md: b9e213b60e0df9de54e4c4805d11e64ca866dad2 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index ccd4b0305c..8614cf3466 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -70,7 +70,7 @@ The whole-value event rule is load-bearing: a state-carrying log event carries t /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value - * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). + * reflects (`-1` for an empty log). */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index 6b62f8898d..b9e213b60e 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -70,7 +70,7 @@ interface ProjectionDefinition< /** * One consistent read cut over every registered client-visible unit for one session. * `asOfSeq` is the shared watermark — the seq of the last event every value - * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`). + * reflects (`-1` for an empty log). */ interface ProjectionSnapshot { /** Seq of the last event the values reflect; -1 for an empty log. */ diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index e9a8d81cbe..eeda73b86b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -591,6 +591,143 @@ The backends that consume this contract are on [persistence.md](persistence.md). Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionController` — `SessionController` + +Host service backing the generated `ctx.remote.session` namespace. + +```ts cordis-catalog +/** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ +resolveAgent(sessionId: SessionId): Promise + +/** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ +@Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise + +/** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ +@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise + +/** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ +@Remote('create') create(request: SessionCreateRequest): Promise + +/** + * Read model choices after explicitly resuming the addressed Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ +@Remote('models') models(request: SessionModelsRequest): Promise + +/** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ +@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise + +/** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ +@Remote('rename') rename(request: SessionRenameRequest): Promise + +/** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ +@Remote('fork') fork(request: SessionForkRequest): Promise + +/** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ +@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise + +/** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ +@Remote('attachment') attachment(request: SessionAttachmentRequest): Promise + +/** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue + +/** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ +@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue + +/** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence and presentation reads. + * @returns one chronological page and optional latest projections. + */ +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns an opened cursor followed by gap-free event frames. + */ +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Settle one still-pending approval or structured question. + * @param request - interaction identity and caller response. + * @returns whether a matching pending interaction accepted the response. + */ +@Remote('respond') respond(request: SessionRespondRequest): SessionRespondReceipt +``` + +Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) + +Source: [`packages/api/session-controller/src/index.ts:66`](../../packages/api/session-controller/src/index.ts) + ### `ctx.sessions` — `SessionStore` @@ -727,6 +864,106 @@ Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persist Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) + + +### `api-session/*` events + + + +#### `api-session/activity` — emit + +One user-authored durable message advanced Session list activity. + +```ts cordis-catalog +/** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ +'api-session/activity'(sessionId: SessionId, updatedAt: number): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:529`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/added` — emit + +A Session became visible to Session list consumers. + +```ts cordis-catalog +/** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ +'api-session/added'(summary: SessionSummary): void +``` + +Source: [`packages/api/session-controller/src/types.ts:509`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/error` — emit + +One Agent failed outside a durable turn position. + +```ts cordis-catalog +/** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ +'api-session/error'(sessionId: SessionId, message: string): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:536`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/removed` — emit + +A Session left the live Host registry. + +```ts cordis-catalog +/** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ +'api-session/removed'(sessionId: SessionId): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:515`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/status` — emit + +One Agent changed running state. + +```ts cordis-catalog +/** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ +'api-session/status'(sessionId: SessionId, running: boolean): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:522`](../../packages/api/session-controller/src/types.ts) + ### `session/*` events diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index ee77131546..08d43d60fb 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -595,6 +595,143 @@ interface TurnEndReasonMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionController` — `SessionController` + +Host service backing the generated `ctx.remote.session` namespace. + +```ts cordis-catalog +/** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ +resolveAgent(sessionId: SessionId): Promise + +/** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ +inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ +@Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise + +/** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ +@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise + +/** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ +@Remote('create') create(request: SessionCreateRequest): Promise + +/** + * Read model choices after explicitly resuming the addressed Session. + * @param request - Session whose model state is requested. + * @returns the current selection and available model groups. + */ +@Remote('models') models(request: SessionModelsRequest): Promise + +/** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ +@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise + +/** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ +@Remote('rename') rename(request: SessionRenameRequest): Promise + +/** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ +@Remote('fork') fork(request: SessionForkRequest): Promise + +/** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ +@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise + +/** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ +@Remote('attachment') attachment(request: SessionAttachmentRequest): Promise + +/** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ +@Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue + +/** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ +@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue + +/** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence and presentation reads. + * @returns one chronological page and optional latest projections. + */ +@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise + +/** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns an opened cursor followed by gap-free event frames. + */ +@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Settle one still-pending approval or structured question. + * @param request - interaction identity and caller response. + * @returns whether a matching pending interaction accepted the response. + */ +@Remote('respond') respond(request: SessionRespondRequest): SessionRespondReceipt +``` + +Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) + +Source: [`packages/api/session-controller/src/index.ts:66`](../../packages/api/session-controller/src/index.ts) + ### `ctx.sessions` — `SessionStore` @@ -731,6 +868,106 @@ Types: [CreateSessionOptions](persistence.zh.md) · [PrepareSessionOptions](pers Source: [`packages/core/session/src/index.ts`](../../packages/core/session/src/index.ts) + + +### `api-session/*` events + + + +#### `api-session/activity` — emit + +One user-authored durable message advanced Session list activity. + +```ts cordis-catalog +/** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ +'api-session/activity'(sessionId: SessionId, updatedAt: number): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:529`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/added` — emit + +A Session became visible to Session list consumers. + +```ts cordis-catalog +/** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ +'api-session/added'(summary: SessionSummary): void +``` + +Source: [`packages/api/session-controller/src/types.ts:509`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/error` — emit + +One Agent failed outside a durable turn position. + +```ts cordis-catalog +/** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ +'api-session/error'(sessionId: SessionId, message: string): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:536`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/removed` — emit + +A Session left the live Host registry. + +```ts cordis-catalog +/** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ +'api-session/removed'(sessionId: SessionId): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:515`](../../packages/api/session-controller/src/types.ts) + + + +#### `api-session/status` — emit + +One Agent changed running state. + +```ts cordis-catalog +/** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ +'api-session/status'(sessionId: SessionId, running: boolean): void +``` + +Types: [SessionId](core.md) + +Source: [`packages/api/session-controller/src/types.ts:522`](../../packages/api/session-controller/src/types.ts) + ### `session/*` events diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index f3135fdb93..bec3a32869 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: 46d9e7c7ef5e5366b165f4dc9217ca9fc02712ab -typert.zh.md: ff92d94f31c9fe1d2e5469cb237751c3f742598e +typert.md: 47c3f4bfcea566a4f2eff480f8b0c7e80ed5ae36 +typert.zh.md: 3feaee7d132c5c7c8b9b602b002bcd82d68e84c6 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 46d9e7c7ef..47c3f4bfce 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -84,6 +84,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -107,7 +109,7 @@ interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -185,6 +187,12 @@ interface TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ + stream(request: InvokeRemoteRequest): Promise> } ``` @@ -231,7 +239,7 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -239,16 +247,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. -```ts cordis-catalog -/** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ -respond(message: ClientResponse): Promise -``` - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:20`](../../packages/host/apiproxy/src/api/index.ts) @@ -314,7 +313,7 @@ toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema Types: [TypertContribution](invariants.md) · [TypertFace](invariants.md) · [TypertPackageFilter](invariants.md) · [TypertPackageRecord](invariants.md) · [TypertSchemaFilter](invariants.md) · [TypertSchemaRecord](invariants.md) -Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) @@ -330,7 +329,14 @@ Resolve strict generated definitions or conservative SRC markers against current * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise + +/** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ +async stream(request: InvokeRemoteRequest): Promise> ``` -Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:109`](../../packages/api/gateway/src/index.ts) diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ff92d94f31..3feaee7d13 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -84,6 +84,8 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string + /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: | { readonly kind: 'direct' } @@ -107,7 +109,7 @@ interface InvocationDescriptor { /** Reserved final Host method parameter. */ readonly parameter: 'signal' } - /** Codec for the resolved method result. */ + /** Codec for the unary result or each yielded stream item. */ readonly result: TypertCodec /** Source declaration used only for diagnostics. */ readonly sourceLocation?: InvocationSourceLocation @@ -185,6 +187,12 @@ interface TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ + stream(request: InvokeRemoteRequest): Promise> } ``` @@ -231,7 +239,7 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { ## Cordis API -Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). @@ -239,16 +247,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. -```ts cordis-catalog -/** - * Response entry for server requests; not a domain method. - * @param message - Client response carrying the server request's rpcId. - * @returns Transport receipt for the response delivery. - */ -respond(message: ClientResponse): Promise -``` - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:20`](../../packages/host/apiproxy/src/api/index.ts) @@ -312,9 +311,9 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[] toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema ``` -Types: [TypertContribution](invariants.zh.md) · [TypertFace](invariants.zh.md) · [TypertPackageFilter](invariants.zh.md) · [TypertPackageRecord](invariants.zh.md) · [TypertSchemaFilter](invariants.zh.md) · [TypertSchemaRecord](invariants.zh.md) +Types: [TypertContribution](invariants.md) · [TypertFace](invariants.md) · [TypertPackageFilter](invariants.md) · [TypertPackageRecord](invariants.md) · [TypertSchemaFilter](invariants.md) · [TypertSchemaRecord](invariants.md) -Source: [`packages/typert/registry/src/service.ts`](../../packages/typert/registry/src/service.ts) +Source: [`packages/typert/registry/src/service.ts:446`](../../packages/typert/registry/src/service.ts) @@ -330,7 +329,14 @@ Resolve strict generated definitions or conservative SRC markers against current * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise + +/** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns an iterable whose items have passed the generated result codec. + */ +async stream(request: InvokeRemoteRequest): Promise> ``` -Source: [`packages/api/gateway/src/index.ts`](../../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:109`](../../packages/api/gateway/src/index.ts) diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 1fe44ec7c0..c6fb54fa12 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 7caf707c376bd3e2654fad1f0c01ac83e6faa44c -README.zh.md: ce3d34480d2a680d0a9ec6be621c3adad7e0ec19 +README.md: ac40c89f314941a7a0fa6fc62ed784961e39dc8d +README.zh.md: ff242369de5f92c67f3e0237b440ab2d114bf0d7 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 7caf707c37..b65907786e 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) @@ -14,11 +14,15 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. + ## Client service: `ClientRemote` (ctx key: `remote`) `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. + +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas, while `RemoteJournalStream` adds follow-before-page opening, cursor deduplication, pagination, reconnect catch-up, and gap repair. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it. @@ -37,6 +41,6 @@ No direct effect; invoked business Services own any model-visible result. - The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypertLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. -- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index ce3d34480d..99bcd8a7b3 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) @@ -14,13 +14,19 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 + +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream;事件名单、参数 JSON 校验和每 Client 队列由 API Remotes 拥有,不进入生成的业务 descriptor。source factory 必须在返回 iterable 前同步挂好增量 listener;Gateway 紧接着先产出 `{ type: 'ready' }`,再迭代 source,让 Client 能在增量通道就绪后才开始 baseline 读取。 + ## Client 服务:`ClientRemote`(ctx key:`remote`) `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。 +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成,`RemoteJournalStream` 则提供 follow-before-page、cursor 去重、分页、重连追赶与缺口修复。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 + +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项将该逻辑流与 `host.describe` 共同组成一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。投递按注册顺序进行;抛错或返回拒绝 Promise 的 listener 会被记录并与其余 listener 隔离。生产方交接不在 `TypertClientRemote` 上公开。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 @@ -37,6 +43,6 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle - Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 -- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`,但不会重放断线期间的事件。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index b9ff0c0323..8b044a6760 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 18d39c6e86f114d2aac2f24e5d15c13335eab020 -README.zh.md: bf3dfbbaa6e0c7bd1da8398977837d4cb19d4688 +README.md: 2a70d47d863d6858528e8f4fda9ec2faa8c408fa +README.zh.md: cab5333c8b40432e5bb624d77482d9daf4279a1d diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 18d39c6e86..2a70d47d86 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and its Host compilation face; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. -`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for Typert `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. -The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The current Client assembly mounts Commands, Goal, dynamic Cordis, read-only Host plugin inventory, message feedback, and Session contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. @@ -28,7 +28,7 @@ The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bun ## Model Experience -None, as this BFF selects Remote application methods and identity policy but registers nothing model-facing. +None, as this BFF selects Remote application methods and forwarded events but registers nothing model-facing. #### KV Cache effect @@ -38,4 +38,3 @@ No direct effect; mounted Host capabilities own any model-visible behavior they - The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. - Additional capabilities require an explicit `/remote` value import and mount in this assembly. -- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index bf3dfbbaa6..2b06100f52 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -2,19 +2,21 @@ [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 -`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 Typert `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) 拥有 Agent 与 Session 身份策略,包括供其他 namespace 使用的 Typert lookup resolver。本包只选择并挂载生成的 Session contribution,不复制激活策略。 -当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +当前 Client 组合挂载 Commands、Goal、动态 Cordis、只读 Host 插件清单、消息反馈和 Session contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和每 Client 独立的 Host event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 ## 转发的 Host 事件 `src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 -监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypertForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 +监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypertForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 + +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在事件入队前逐参数拒绝非 JSON 值。该 source 在 factory 返回前同步挂好所有 listener,再通过 `ctx.typertGateway.registerRemoteEvents()` 接到 Gateway 内部的 `$events` logical stream;这个顺序让 Gateway 的首个 `ready` 项能够作为增量投递已就绪的证明。撤回注册会中止仍在活动的 stream;API Proxy 不参与事件转发或 Connection generation。 ## 构建边界 @@ -29,7 +31,7 @@ ## 模型体验 -无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 +无,因为该 BFF 只选择 Remote 应用方法和转发事件,不注册任何模型接口。 #### KV Cache 影响 @@ -39,4 +41,4 @@ - 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 - 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 -- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 +- 转发事件不重放;需要可靠恢复的状态必须由 owner 提供查询、cursor 或 opening baseline。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml new file mode 100644 index 0000000000..b13f9f8a1a --- /dev/null +++ b/packages/api/session-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md +README.md: 55e5894ad1e8d2ab1f6c4c8373e87c3fc4fe397f +README.zh.md: 2c308902088d8c445fb6f421111d514bf84e9de3 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md new file mode 100644 index 0000000000..d3df103c1c --- /dev/null +++ b/packages/api/session-controller/README.md @@ -0,0 +1,22 @@ +# Session Controller + +English | [中文](README.zh.md) + +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, Host-wide control state, and pending-interaction responses. + +Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation, cancellation, and interaction responses require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. + +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, projection, approval, and question state instead of treating transient values as durable events. + +## Model Experience + +None, as invoked Agent commands own any model-visible effect. + +#### KV Cache effect + +No direct effect; model requests remain owned by the Agent and LLM packages. + +## Known Limitations and Deferred Work + +- Control baselines represent process-local state and therefore cannot reconstruct pending interactions or jobs after a Host restart. +- A failed follow resumption remains visible to the caller instead of retrying indefinitely. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md new file mode 100644 index 0000000000..be6afc31db --- /dev/null +++ b/packages/api/session-controller/README.zh.md @@ -0,0 +1,22 @@ +# Session Controller + +[English](README.md) | 中文 + +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随、Host 范围 control 状态和 pending interaction 响应。 + +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更、取消和 interaction 响应要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 + +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs、projection、approval 和 question 状态,而不会把瞬态值当作 durable event。 + +## 模型体验 + +无,因为被调用的 Agent 命令拥有任何模型可见效果。 + +#### KV Cache 影响 + +无直接影响;模型请求仍由 Agent 和 LLM 包拥有。 + +## 已知限制与延期工作 + +- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 pending interaction 或 jobs。 +- follow 恢复失败会对调用方可见,而不会无限重试。 diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6d33ac3c13..2f372bdc46 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,15 +2,21 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。导出的 `ClientTransportHooks` 命名了整体替换浏览器载体的页面全局量 `__DSH_TRANSPORT__`:served web app 不设置它、走 HTTP + WebSocket;拥有另一种物理传输的壳(worker 预览的 postMessage 隧道)则在此提供 `createApiClient` 与 `fetch`——当它同时持有 bundle 字节时再加 `loadBundle`——而不必 fork 本插件。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md)。 +协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 + +浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge 和信任校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定留在包内:Host fence 与 WebSocket upgrade 直接使用它,其他 Client 插件消费 `ctx.connection.isLoopback`。 + +node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,整个 settings 与 credentials 配置面,`llm.discoverModels`,以及 `agentPreset.read`/`copy`/`openDocument`/`remove`)以空信任表过 fence,从而钉在回环本机。`agentPreset.list` 与 `agentPreset.select` 不在其中:名单只携带 id 与信任级别,而 `session.create` 已能选择 preset。已声明的 `trustedHosts` 授权可达其余方法;在真正的认证层出现前,特权面始终只限回环。 ## /api 浏览器信任栅栏 node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)。 -## `/api` WebSocket 下行 +## Connection generation -`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` 文本消息;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket 均已打开且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE(Server-Sent Events)回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 + +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 ## 模型体验 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 270d99652d..9e1d4a7f6d 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 75c409fa3f1b517e95a48505122e9c21b6f9b0cc -README.zh.md: 3aca97e7e641a2e9dd81f399348bac9c109dbc0e +README.md: 5d9536b52720841a10c731e77077d0dd2262d625 +README.zh.md: aec7a5fa96b030db4c519f8faf5156c2112e3f07 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 75c409fa3f..adeeca51ef 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. Each durable window uses one Session Controller journal stream; one Host-wide snapshot stream supplies queue, jobs, projections, approvals, and questions. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`); Workspace Controller supplies its reconnecting snapshot stream. Domain packages subscribe to forwarded Host events through `ctx.remote.$on`. + +Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state. A session's Agent scope, the client mirror of Host dsh-scope keyed by the shared Agent/Session id, is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from Session list or page projection blocks and updated by control-stream projection replacements under higher-seq-wins. Domain keys, including `todos`, are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current `Intl.DateTimeFormat().resolvedOptions().timeZone` and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state. @@ -18,7 +20,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspaceRuntime.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. -`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending. +`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks control-stream requested/resolved frames by stable `interactionId` even before a Session object is instantiated; pre-instantiation state retains every live request, replaces duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. Every control generation begins with a complete baseline that replaces the pending set and therefore restores only requests that remain answerable. `WorkspaceRuntime.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. @@ -30,19 +32,19 @@ SlotRegistry gives the renderer separate bare observables for `useSessions` and `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. -`SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. +`SessionListState.jobsBySession` mirrors the Session Controller control stream, keyed by Session and needing no Session instance. Each control baseline replaces the complete map; later `jobs` frames are last-wins replacements for one Session. An empty set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. The forwarded `api-session/removed` event also clears that Session's jobs. -`SessionRuntime.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. +`SessionRuntime.search(query, signal)` is a stateless one-shot action over `ctx.remote.session.search`. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror -`WorkspaceRuntime.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionRuntime.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`WorkspaceRuntime.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the Host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `ctx.remote.session.create({workspaceId})`, returning the Session id for the caller to open. The shared `startSession` action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. `SessionSummary.blank` mirrors the Host's derived empty-log bit and only ever lowers on the client: it is seeded by `session.list` or `api-session/added`, flips false after the first accepted local `prompt()` and on any `api-session/status` event with `running: true`, and is re-aligned by every list pull. List surfaces hide blank rows; the store carries every row. `SessionRuntime.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` carrying `requestedSessionId` on failure. `Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. +`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of both `agent.inbox.nextTurn` and `nextStep`. Rows are tagged `queued`, `steering`, or `context`; each carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. Every control generation starts with complete queue snapshots, and later `queue` replacements follow `agent/inbox/spliced` changes; message-local inserted, claimed, and discarded notifications are not used to reconstruct the projection. `Session.updateQueue()` sends mutations without optimistic client state, so the next Host snapshot is the visible commit and a claim race can surface `queue-item-not-found`. ## Conversation assembly @@ -64,7 +66,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start ## Session title projection -`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op. +`SessionManager` retains the generic `title` projection independently of list and Session-instance arrival. Session list summaries, page tails, and control frames all seed the same store; higher event seqs replace lower ones, and a replacement control baseline truncates rows beyond its watermark before seeding its values. Explicit Session removal clears the store. List `updatedAt` is Host-owned and derives from the latest human prompt, so a title change does not affect recency. The client-facing `SessionSummary.title` is only the durable title; `displayTitle` is always present and falls back through the cwd basename and Session id. `ISession.rename` settles the `title` projection cell directly from the Remote response's `{title, seq}` under the same higher-seq-wins rule, so list and `useProjection('title')` readers update before a later replay of the same seq. ## Model retry projection @@ -76,17 +78,17 @@ A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at `ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. -## Session model selection +## Model selection ownership -Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure. +Session Runtime carries no model-selection snapshot. `ui-model-selection` owns one scoped `ModelDirectory` per Session and calls `ctx.remote.session.models` and `selectModel` directly; Runtime supplies only Session scope and address information. That package resets its directory on `connection/reset`, shares one latest-generation-wins store between its two selectors, and disposes it with the Session scope. ## Model Experience -None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content. +None, as this package adds no model-visible content; model selection belongs to `ui-model-selection` and the Host Session Controller. #### KV Cache effect -Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. +None directly; this package neither selects a model nor alters the prompt prefix. ## Known Limitations and Deferred Work diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 3aca97e7e6..8235593bf0 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotRegistry 包装 SlotCore 并提供 renderer 数据源;SessionRuntime 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspaceRuntime 依赖 SessionRuntime,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotRegistry 包装 SlotCore 并提供 renderer 数据源;SessionRuntime 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。每个持久窗口使用一条 Session Controller journal stream;一条 Host 级 snapshot stream 提供 queue、jobs、projection、approval 与 question。WorkspaceRuntime 依赖 SessionRuntime,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`);Workspace Controller 提供它的可重连 snapshot stream。各领域包通过 `ctx.remote.$on` 订阅 Host 转发事件。 + +客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、Agent 和 cwd);客户端不持有任何实体化之前的会话状态。Agent scope 是 Host dsh-scope 的客户端镜像,以 Agent/Session 共用 id 为键,在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由 Session 列表或 page 的 projection block 播种,并经 control 流的 projection replacement 按 seq 高者胜更新。领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 对于每条可到达本地根 Agent 或可继续子 Agent 的提示词,运行时都会采样浏览器当前的 `Intl.DateTimeFormat().resolvedOptions().timeZone`,并只把该值附加到这一次 Session 或 subagent 提示词 RPC。该值既不缓存,也不包含在 Session 创建或 fork 状态中,因此旅行与并发标签页都能保留消息本地的来源信息。浏览器若无法提供非空时区,会在本地拒绝该提示词,而不会悄然使用部署状态代替。 @@ -20,7 +22,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspaceRuntime.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 -`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除,mux 打开时的回放只恢复仍处于 pending 的请求。 +`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval`、`plan-review` 或 `question`。`SessionManager` 依据稳定的 `interactionId` 跟踪 control 流的 requested/resolved 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的状态会保留每个仍有效的请求、替换重复项并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。每一代 control 都以完整 baseline 开始并替换 pending 集合,因此只恢复仍可应答的请求。 `WorkspaceRuntime.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。 @@ -32,19 +34,19 @@ SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 -`SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 +`SessionListState.jobsBySession` 镜像 Session Controller control 流,以 Session 为键,不需要 Session 实例。每份 control baseline 替换完整映射;后续 `jobs` 帧按 last-wins 替换单个 Session。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。转发的 `api-session/removed` 事件也会清除该 Session 的 jobs。 -`SessionRuntime.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 +`SessionRuntime.search(query, signal)` 是基于 `ctx.remote.session.search` 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 -`WorkspaceRuntime.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionRuntime.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`WorkspaceRuntime.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 Workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——Host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `ctx.remote.session.create({workspaceId})`,返回 Session id 由调用方 open。共享的 `startSession` 操作优先使用明确指定的 Workspace,其次使用当前 Session 所属 Workspace,再其次使用派生的最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。`SessionSummary.blank` 镜像 Host 派生的空日志位,在客户端只降不升:由 `session.list` 或 `api-session/added` 播种,本地首次获 Host 接受的 `prompt()` 后与任何 `running: true` 的 `api-session/status` 事件都会将其翻为 false,每次列表拉取重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionRuntime.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出携带 `requestedSessionId` 的 `SessionCreateError`。 `Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则可能呈现 `queue-item-not-found`。 +`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 与 `nextStep` 权威瞬态快照。各行标记为 `queued`、`steering` 或 `context`,并携带 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。每一代 control 都以完整 queue 快照开始,后续 `queue` replacement 跟随 `agent/inbox/spliced` 变更;面向单条消息的 inserted、claimed 与 discarded 通知不用于重建该投影。`Session.updateQueue()` 不做乐观客户端变更,下一份 Host 快照才是可见提交结果,claim 竞态则可能呈现 `queue-item-not-found`。 ## Conversation 组装 @@ -66,7 +68,7 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别 ## Session 标题投影 -`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。 +`SessionManager` 独立于列表和 Session 实例到达情况,保留通用的 `title` projection。Session 列表摘要、page 尾部与 control 帧都会播种同一个 store;seq 更高的事件替换较低值,而替换 control baseline 会先截断超过其 watermark 的行,再播种自己的值。显式移除 Session 也会清除该 store。列表 `updatedAt` 由 Host 所有并根据最近一次真人 prompt 派生,因此标题变化不影响新近程度。面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。`ISession.rename` 用 Remote 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则,因此列表行和所有 `useProjection('title')` 读者会在后续同 seq 回放前更新。 ## 模型重试投影 @@ -78,17 +80,17 @@ reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-m `ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端根据源会话的持久化标题重命名子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 -## 会话模型选择 +## 模型选择所有权 -每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前模型选择、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前模型选择,打开选择器会刷新目录;选择失败会保留上一次模型选择和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的模型选择。重连重建会恢复 Host 报告的模型选择,同时不替换未变化的选择子结构。 +Session Runtime 不携带模型选择快照。`ui-model-selection` 为每个 Session 拥有一个 scope 绑定的 `ModelDirectory`,并直接调用 `ctx.remote.session.models` 与 `selectModel`;Runtime 只提供 Session scope 和地址信息。该包在 `connection/reset` 时重置目录,让两个 selector 共用一份 latest-generation-wins store,并随 Session scope 销毁它。 ## 模型体验 -无,因为会话对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。 +无,因为本包不添加模型可见内容;模型选择由 `ui-model-selection` 与 Host Session Controller 所有。 #### KV Cache 影响 -更改模型选择可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 +无直接影响;本包既不选择模型,也不改变提示词前缀。 ## 已知限制与暂缓事项 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6b19d4220f..21ae72ba2e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -92,6 +92,7 @@ export const SERVICE_PAGE: Record = { sessionReferenceResolver: 'session-reference.md', sessionProjectionCache: 'session-projection.md', sessionProjections: 'session-projection.md', + sessionController: 'session.md', sessions: 'session.md', settings: 'settings.md', sessionTitle: 'session-title.md', @@ -177,6 +178,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'agent': 'core.md', 'agent-loop': 'core.md', 'agent-preset': 'core.md', + 'api-session': 'session.md', 'approval': 'approval.md', 'commands': 'commands.md', 'cordis': 'extensions.md', @@ -279,6 +281,7 @@ export const LINK_MAP: Readonly> = { MessageFeedbackVersion: 'feedback.md', MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', + ApiSessionAgentResult: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', RequestErrorAction: 'core.md', @@ -288,8 +291,37 @@ export const LINK_MAP: Readonly> = { SessionReferenceCandidate: 'session-reference.md', SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', + SessionAttachmentRequest: 'session.md', + SessionAttachmentValue: 'session.md', + SessionCancelRequest: 'session.md', + SessionCancelValue: 'session.md', + SessionControlFrame: 'session.md', + SessionCreateRequest: 'session.md', + SessionCreateValue: 'session.md', SessionEvent: 'session.md', + SessionFollowFrame: 'session.md', + SessionFollowRequest: 'session.md', + SessionForkRequest: 'session.md', + SessionForkValue: 'session.md', SessionId: 'core.md', + SessionListRequest: 'session.md', + SessionListValue: 'session.md', + SessionModels: 'session.md', + SessionModelsRequest: 'session.md', + SessionPage: 'session.md', + SessionPageRequest: 'session.md', + SessionPromptRequest: 'session.md', + SessionPromptValue: 'session.md', + SessionRenameRequest: 'session.md', + SessionRenameValue: 'session.md', + SessionRespondReceipt: 'session.md', + SessionRespondRequest: 'session.md', + SessionSearchValue: 'session.md', + SessionSelectModelRequest: 'session.md', + SessionSelectModelValue: 'session.md', + SessionSummary: 'session.md', + SessionUpdateQueueRequest: 'session.md', + SessionUpdateQueueValue: 'session.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1aeb42b436..1508bcaa55 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -149,6 +149,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, + { + key: 'sessionController', + pkg: 'api-session-controller', + title: 'Host Session Remote controller', + mode: 'core', + consumers: ['apiproxy'], + note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.', + }, { key: 'invariants', pkg: 'invariants', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2cc5dcd2a8..76a1fcfd59 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -70,7 +70,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, - 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' }, + 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, @@ -155,6 +155,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, + 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' }, 'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' }, 'packages/jobs/jobs': { kind: 'indirect', reason: 'Producer and controller plugins own all model rendering over the job registry.' },