@@ -10,7 +10,7 @@
会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header` ,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。
`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection` ,包括适配器实体化的默认推理强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。
`session.selectModel` 会把接受的切换保存为部署默认值;没有单独的选择动作。它存储已解析的 `ModelSelection` ,包括适配器实体化的默认推理( reasoning) 强度。完整分节写入会在所选模型没有推理强度时清除已存值。存储失败只记日志,不会撤销会话选择。没有设置提供方的部署保留组合条目,切换只对当前会话生效。
Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中刻意没有对应字段: seam 按字段把用户层合并到组合条目之上,因此缺席的键无法覆盖已有键,组合层中的推理强度会在以后选择没有推理强度的模型时继续存在。推理强度的部署默认值属于按模型解析的适配器 profile。
@@ -18,9 +18,9 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
## 约定层(`/api`)
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest` ( POST `/api/<method>` 的请求体)、`ServerResponse` (该 POST 的响应体)、`ServerRequest` ( SSE 帧)和 `ClientResponse` ( POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId` ,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi` 、`HostApi` 、`EventsApi` )中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>` / `ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合) ; HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest` ( POST `/api/<method>` 的请求体)、`ServerResponse` (该 POST 的响应体)、`ServerRequest` ( SSE( Server-Sent Events) 帧)和 `ClientResponse` ( POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId` ,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi` 、`HostApi` 、`EventsApi` )中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>` / `ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合) ; HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站「简单请求」(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC ](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md )中;浏览器侧消费架构记录在 [Web 客户端架构 RFC ](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md )中。
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC ](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md ) 中;浏览器侧消费架构记录在 [Web 客户端架构 RFC ](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md ) 中。
首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本; 单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。
@@ -34,15 +34,15 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
`session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end` ,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable` , 不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id, 供客户端对账。[SessionStore fork 决策 ](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md )记录了为何锚点要映射到该 `turn/end` 。
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理( reasoning) 元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable` 。`session.models` 还会报告 `routable` ,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
会话模型选择属于会话领域约定。`session.models` 将当前 `ModelSelection` 与按提供方分组的建议性模型、精确模型的推理元数据和逐提供方查询失败记录分开返回。该选择可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户作出另一项选择,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并指定下次组装提示词时使用的完整选择。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用的提供方或不受支持的推理强度会返回 `model-unavailable` 。`session.models` 还会报告 `routable` ,即当前是否有适配器为所选提供方提供服务。该值刻意不从分组推导,因为适配器可以服务未公布的模型。`session.prompt` 会依据同一事实,在开启轮次之前以 `model-unavailable` 拒绝;客户端禁用 composer 只是提示性设计,这个方法始终可被调用。
`session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值, Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering( 中途引导) 不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement, 而注入上下文( 审批通知、任务完成、附加快照) 携带 `context` ,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted` 、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found` 。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后, AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering( 中途引导) 不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement, 而注入上下文( 审批通知、任务完成、附加快照) 携带 `context` ,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted` 、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。认领操作 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found` 。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后, AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算, 以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline( 没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]` )。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确; baseline 读 `ctx.agents.get(sessionId)` ,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession` 、`reported` 和 `outputLimitBytes` :第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id, `host/workspace-changed` 、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时 也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank, 并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id, `host/workspace-changed` 、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话 也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank, 并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/ snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1, 并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
@@ -52,13 +52,13 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径( macOS 为 `open` , Windows 为 `Invoke-Item` ,桌面 Linux 为 `xdg-open` )。对于 `.html` 、`.htm` 、`.xhtml` 与 `.svg` , macOS 和桌面 Linux 会优先使用能够确定的默认浏览器; 无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item` ,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux, 并拒绝 headless/ 容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust` ( `user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id, 界面必须能展示并删除它, 而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent, 且仅在会话空白时允许: 一旦跑过任何轮次, 那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录 tool call ,此时返回 `agent-preset-locked` 。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
`agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust` ( `user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id, 界面必须能展示并删除它, 而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent, 且仅在会话空白时允许: 一旦跑过任何轮次, 那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用 ,此时返回 `agent-preset-locked` 。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。
`agentPreset.read` 、`copy` 、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust` ,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }` ——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid` , `remove` 对随附 preset 回答 `agent-preset-read-only` 。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径, 因此没有任何浏览器载荷能选中任意文件系统目标; 部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath` )失真处钉死该能力。这四个方法在 [`dsh-client-connection` ](../../client/connection/README.md ) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable` ,即部署是否配置了可供复制新 preset 的根目录;`hasDocument` ,即 `openDocument` 会原生打开、还是回答一个路径。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill( 技能) 目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent( 被服务的会话必有 Agent; `command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation` )的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt` , `dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token, 并以注入的 `<skill_content>` 上下文作答, 因此所有入口( Web、TUI 与 ACP( Agent Client Protocol) ) 共享同一条确定性路径, 手动键入的文本也走该路径, 且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId` (将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run` /`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope, 不产生任何注册, 因此该会话组成所决定的两份目录( `command.list` 、`skill.list` )都会失效,却没有任何注册表变化来宣告它。
`settings.*` 、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace( `ctx.llm.listConfigurableProviders()` ),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale` 、`permission` 、`ui-conversation` 与 `ui-theme` 、插件配置页所编辑的宿主平面插件分节 `agent-loop` 、`bash` 与 `web-search-deepseek` ,以及产品持有的 `ui-onboarding` ;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed` ——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值( resolved/`base` /`user` ——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision` ,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update` /`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op( `set` /`unset` ),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision` ;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected` 。secret 角色的值绝不在任何一层搭乘任何响应; secret 只沿一个方向跨越协议——在 `update` /`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured` /`source` /`writable` ) , `credentials.set` /`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected` 。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false` ;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update` /`mutate` 与 `credentials.set` ) , 且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据) 都折叠为 `model-discovery-failed` , 其消息是适配器自己的文本, details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe` /`openDocument` /`update` /`replace` /`mutate` 与 `credentials.describe` /`set` /`unset` )限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`settings.*` 、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace( `ctx.llm.listConfigurableProviders()` ),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale` 、`permission` 、`ui-conversation` 与 `ui-theme` 、插件配置页所编辑的宿主平面插件分节 `agent-loop` 、`bash` 与 `web-search-deepseek` ,以及产品持有的 `ui-onboarding` ;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed` ——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值( resolved/`base` /`user` ——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision` ,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update` /`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op( `set` /`unset` ),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision` ;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected` 。secret 角色的值绝不在任何一层搭乘任何响应; secret 只沿一个方向跨越协议——在 `update` /`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured` /`source` /`writable` ) , `credentials.set` /`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected` 。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false` ;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update` /`mutate` 与 `credentials.set` ) , 且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据) 都折叠为 `model-discovery-failed` , 其消息是适配器自己的文本, details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe` /`openDocument` /`update` /`replace` /`mutate` 与 `credentials.describe` /`set` /`unset` )限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据提供方 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
## 载体层(`/client` + 根路径)
@@ -80,4 +80,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下, Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README ](../directory-picker-native/README.md ))。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime, 而每一次持久写入都会刷新它, 包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL; SQLite 返回 `undefined` ,因此它的冷会话回退到 `createdAt` ,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见[最后活动索引 Agent Note ](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md )。
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime, 而每一次持久写入都会刷新它, 包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL; SQLite 返回 `undefined` ,因此它的冷会话回退到 `createdAt` ,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见 [最后活动索引 Agent Note ](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md )。