docs: complete Chinese proofreading and generated reference pairing

This commit is contained in:
xjt
2026-08-09 11:02:16 +08:00
parent 0543a6f79d
commit 7ff0cbcb7e
565 changed files with 10606 additions and 2171 deletions

View File

@@ -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/feature/2026-06-21-subagent-capability-seam.md
2026-06-21-subagent-capability-seam.md: 752e639b09ea2ac0ba19841ddfe38b15b44d22e9
2026-06-21-subagent-capability-seam.zh.md: 6009aeda6773357a4217f8956fb510c2116bbe3f
2026-06-21-subagent-capability-seam.zh.md: f0b0ba2ab8b0bb7b611e995c56e30db68b21cfa7

View File

@@ -1,37 +1,37 @@
# Agent Note: Subagent 能力 seam
# Agent Note: subagent 能力 seam
Status: implemented
[English](2026-06-21-subagent-capability-seam.md) | 中文
> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent 快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外的 ACP、Codex 与 Claude Code 后端([ACP Agent Note](2026-06-22-acp-subagent-backend.md)、[产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md))。
> 完整 seam 已交付:`dsh-subagent` 接口与 `dsh-tool-subagent` 消费方;两个进程内后端(`dsh-subagent-spawn`、`dsh-subagent-fork`);嵌套 agent(智能体)快照基础设施([逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md));以及进程外的 ACP(Agent Client Protocol)、Codex 与 Claude Code 后端([ACP Agent Note](2026-06-22-acp-subagent-backend.md)、[产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md))。
## 问题
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent(智能体)将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 Agent Note 实现了这个 seam;上方横幅列出了已交付的内容。
harness 有一个长期搁置的 seam 用于 **subagent**:一个 agent 将工作委派给另一个 agent。这一意图在 `Agent`/`AgentLoop` 接口中已有草案([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts)、[packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)):一个创建选项引用父 agent(fork = 用父会话的事件日志初始化子会话;spawn = 全新会话),子 agent 以 `Agent` 句柄返回,使 steering(中途引导)和事件订阅可以统一工作。本 Agent Note 实现了这个 seam;上方横幅列出了已交付的内容。
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP(Agent Client Protocol))。我们预见的传输方式:
决定整体设计走向的核心需求是:**多种 subagent 实现必须在运行时共存**。一个父 agent 可能在同一个会话中既需要一个廉价的进程内子 agent 处理有限范围的子任务,又需要一个隔离的进程外子 agent(通过 ACP)。我们预见的传输方式:
- **进程内**:在同一个 `Context` 上创建一个具体的子 `Agent`(最廉价,且鉴于现有 agent 工厂几乎零成本);
- **ACP**:作为 ACP *客户端*驱动另一个 agent 进程(可以是自身的另一个实例);
- **Codex app-server 与 Claude Code Agent SDK**:当前的一次性兄弟提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md));
- **Codex app-server 与 Claude Code Agent SDK**:当前的一次性同类提供方,将同一个命名提供方 seam 应用于官方产品进程([产品提供方 Agent Note](2026-08-04-claude-code-and-codex-subagent-backends.md));
- 后续:**A2A**,采用同样的进程外形态:「启动子 agent、发送提示词、结算、取消」。
## 曾考虑的替代方案
### 为何不采用 bash seam 的形状
bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个上下文中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口 / 实现 / 消费方);只是「一个 vs. 多个实现」这个维度不同。
bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在每个上下文中只注册恰好一个 `BashExecutor`;加载第二个会抛异常。这对 bash 是正确的(一台机器、一种执行命令的方式),但对这里是错误的:共存才是需求。因此 subagent 服务是一个**命名提供方注册表**——每个实现以唯一名称注册,调用方按名称选择——镜像 **LLM(大语言模型)适配器注册表**(`LlmService.registerAdapter`),而非单服务的 bash 执行器。seam 仍然是由三个包构成的结构(接口、实现、消费方);只是「一个 vs. 多个实现」这个维度不同。
## 决策
### 由三个包构成的 seam
新建包(package)组 `packages/subagent/`:
新建包组 `packages/subagent/`:
| 包 | 角色 |
|---|---|
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService`(`ctx.subagents`)、`SubagentProvider`、`SubagentRun`、请求/结果/能力词汇、`subagent/*` 事件 |
| `@deepseek-ai/dsh-subagent` | 接口:`SubagentService`(`ctx.subagents`)、`SubagentProvider`、`SubagentRun`、请求、结果、能力词汇、`subagent/*` 事件 |
| `@deepseek-ai/dsh-subagent-spawn` | 实现:通过 `ctx.agents.create` 创建全新的进程内子 agent |
| `@deepseek-ai/dsh-subagent-fork` | 实现:用父 agent 日志快照初始化的进程内子 agent |
| `@deepseek-ai/dsh-subagent-acp` | 实现:作为 ACP 客户端驱动已配置的子进程 |
@@ -45,8 +45,8 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
### 两类可选能力,两种发现方式
- **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**大声拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。
- **可继续创建**使用可选的 `SubagentProvider.prepareContinuable` 方法;方法是否存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flag。继续执行管理器直接通过 `AgentHandle` 负责后续投递与从持久化存储恢复,而一次性 `SubagentRun` 没有 steering 或 resume 操作,具体由[可继续 subagent](2026-07-28-continuable-subagent-conversations.md)细化。
- **启动时功能**(`outputSchema`、`depthLimit`、`toolFilter`、`persona`)挂在静态的 `provider.capabilities` 描述符上。服务在委派之前检查每个被请求的功能,如果提供方不支持则**明确拒绝**(`SubagentError('UNSUPPORTED_CAPABILITY')`),绝不接受后静默忽略。这些功能必须在 run 存在之前检查,因此不能是运行时方法。
- **可继续创建**使用可选的 `SubagentProvider.prepareContinuable` 方法;方法是否存在本身即为能力,TypeScript 类型收窄即为发现机制,因此不需要可能与实现失同步的独立 flag。继续执行管理器直接通过 `AgentHandle` 负责后续投递与冷恢复,而一次性 `SubagentRun` 没有 steering 或 resume 操作,具体由[可继续 subagent](2026-07-28-continuable-subagent-conversations.md) 细化。
### Fork 与 fresh 是独立后端,而非一个 flag
@@ -58,7 +58,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
### 同步收集(首版)
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;相互独立的结果与 dispose rejection 会保留两项 diagnostic。
`dsh-tool-subagent` 将其执行信号传给 `start()`,等待子 agent 结果,并在报告前 dispose 该 run。非完成态的结果变为错误结果,而非成功的部分输出;结果与 dispose 的拒绝相互独立,且两项诊断信息都会保留。
### 提供方选择是配置,不面向模型
@@ -66,7 +66,7 @@ bash seam([能力 seam](../architecture/2026-06-13-capability-seams.md))在
## 测试
注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。
注册表与工具测试仅用包内脚本化提供方替换非确定性的子进程边界,同时运行真实的 `SubagentService`、生命周期、任务集成和面向模型的工具。提供方与消费方的 export 形状仍保留 Loader 回归覆盖,以防止[事故复盘(postmortem)0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的失败。注册表测试覆盖重载安全性、重名和启动时能力拒绝;嵌套 agent 场景通过[逐会话快照回放](../testing/2026-06-22-subagent-snapshot-replay.md)进行无密钥回放;进程内后端还有真实循环的单元测试和带密钥的 e2e 测试。
## 后果

View File

@@ -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/feature/2026-07-05-skill-system.md
2026-07-05-skill-system.md: 5961c535a1397c487b0255f8a27d60929c2eeace
2026-07-05-skill-system.zh.md: 05bd880ecbd46f2b472eb2cc5442f11c07f89b4e
2026-07-05-skill-system.zh.md: fe3cf200278c2dd5449e1442228419845593056b

View File

@@ -1,4 +1,4 @@
# Agent Note: Skill 系统——面向 agent 的渐进式指令披露
# Agent Note: skill 系统——面向 agent 的渐进式指令披露
Status: implemented
@@ -6,7 +6,7 @@ Status: implemented
## 问题
Agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。
agent(智能体)产品已趋同于一种 skill(技能)模式:保持请求提示词精简,仅列出可用的指令包,当模型判定某任务匹配时再加载完整正文。Codex、Claude Code、OpenCode 与 Kimi Code 在细节上各有不同,但都将发现元数据与完整指令分离,使工作区能承载可复用的行为而无需在每个轮次支付全量提示词开销。
DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和工具使用指南存放在工作区或用户的 agent 配置旁,而非硬编码到 agent loop(智能体循环)中。
@@ -16,31 +16,31 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和
专用的随包提供方可以贡献不可变的 skill,无需文件系统发现。交付的 CLI(命令行界面)默认将 `@deepseek-ai/dsh-skill-badge` 声明为禁用;启用其组合配置行,就会通过同一个注册表和消费方贡献官方徽章指令(见[决策](2026-08-06-bundled-dsh-badge-skill.md))。
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由 effect 直接持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话目录中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。
本地提供方按先到先得的排名顺序扫描 cwd 敏感的项目根目录、自定义根目录和用户根目录:项目 `.dsh`、项目 `.agents`、`customSkillDirs`、用户 `.dsh`,然后是用户 `.agents`。用户 `.dsh/skills` 扫描跳过 `.system`,以免系统拥有的目录被当作普通用户内容处理。本地提供方不会合成内置系统 skill;已配置的 bundled 根目录和专用提供方会提供额外 skill。
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包(package)解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包有限 frontmatter 需求已声明的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
每个 skill 是 `<name>/SKILL.md` 或带 YAML frontmatter 的 `<name>.md`。`name` 和 `description` 为必填;`whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable` 为可选。名称采用 kebab-case。调用字段会投影到类型化的嵌套策略中,具体由[模型与用户独立调用决策](2026-07-28-skill-invocation-policy.md)定义;解析器会拒绝旧的驼峰拼写。YAML frontmatter 使用 `yaml` 包解析,而非 `js-yaml` 或手写解析器:`yaml` 是本包已声明用于满足有限 frontmatter 需求的现代解析器,窄解析器要么拒绝用户预期可用的合法 YAML,要么膨胀为一个未经评审的 YAML 子集。
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方契约。
本地 skill 的文件系统 I/O 在加载了文件系统服务时通过 `ctx.fs` 进行:项目根目录查找使用 `resolve` 和 `stat` 探测 `.git`,根目录发现使用 `listDir`,skill 读取使用 `readText`。Node 文件系统作为后备,供在不挂载 fs seam 的最小上下文中加载 `dsh-skill-local` 时使用。缺失的根目录、不可读或格式错误的 skill 文件、以及提供方 `list()` 的瞬态失败均降级为警告并跳过,使一个坏源不会导致所有 agent 请求失败;格式错误的候选项仍然快速失败,因为它们违反了提供方约定。
`dsh-tool-skill` 在会话的第一个 `agent/pre-step` 注入一个持久化的 user-role `<system-reminder>` 目录,作为带来源的 `user/message`,且仅当该 agent 的工具视图解析到本插件精确的 `skill` 注册时才注入。该目录仅包含排序后的 skill 名称与描述;不包含正文、路径、来源、提供方和路由提示。描述经过空白规范化、XML 转义,并受 `catalogDescriptionMaxLength` 上限约束,其默认值为 `500`,最小值为 `3`。完整的 skill 正文从不包含在目录中。(目录最初通过仅请求的[会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)传递;[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)将其移入持久化历史。)
注册表的 `list()` 返回全部胜出摘要,而模型与用户消费方应用[独立调用策略决策](2026-07-28-skill-invocation-policy.md)定义的调用判定。`skill({ name })` 工具为当前 agent cwd 加载一个模型可调用的 skill,返回包含 `<skill_content name="...">`、`<skill_resources>` 和 `<skill_instructions>` 的工具结果。`resourceBase` 提供一个目录、URL 或不透明的提供方管理的基路径,用于显式引用的脚本、参考资料和资产;资源仅按需加载,不进行目录枚举。无法解析的名称报告该 skill 未知或不再可用;无效名称和 `invocation.modelInvocable` 为 `false` 的 skill 保留不同的工具错误。工具结果是面向模型的可见披露路径。
数据结构与目录/工具契约记录在 [skills.md](../../../../docs/subsystems/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/subsystems/skills.md#cordis-surface)。
数据结构与目录/工具约定记录在 [skills.md](../../../../docs/subsystems/skills.md) 中,服务签名见生成的[服务目录](../../../../docs/subsystems/skills.md#cordis-surface)。
## 曾考虑的替代方案
**将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;面向人类的命令广播不改变发现机制。
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;面向人类的命令展示不改变发现机制。
**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现拥有加载。
**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现负责加载。
**使用系统提示词段落。** 否决,因为渲染后的系统提示词是单一字符串,而目录是一条 user-role `<system-reminder>` 消息。[仅请求的会话前缀 seam](../../archived/feature/2026-07-07-session-prefix.md)(已归档)是最初的机制;统一带来源消息的决策移除该 seam 后,目录改为具有相同消息形状的持久化带来源注入。
**在 `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill。** 否决,因为打包的 skill 不应在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
**在 `~/.dsh/skills/.system` 下物化内置 DSH 编写 skill。** 否决,因为打包的 skill 不会在启动时写入用户主目录,嵌入式或远程提供方在配置后提供 skill。
**递归发现嵌套的 `**/SKILL.md`。** 否决。扁平文件和一级目录包覆盖了配置的根目录,同时使重复处理和目录顺序易于推理。
@@ -48,10 +48,10 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和
## 后果
agent-core 主干包含一个目录贡献者、一个本地提供方和一个面向模型的工具。Skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
agent-core 主干包含一个目录贡献者、一个本地提供方和一个面向模型的工具。skill 发现是 cwd 敏感的,因此以不同会话 cwd 值创建 agent 的调用方可以按设计观察到不同的项目 skill 覆盖。
目录对于固定的根目录集合和运行时注册修订版本是确定性的。本地提供方会监视已配置的根目录,并在发生相关磁盘变化后使已完成的目录失效;运行时注册和提供方释放也会使其失效。
## 延后
Fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的契约范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不执行这些字段。直接用户调用曾作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见[已归档的 TUI skill 斜杠命令](../../archived/feature/2026-07-21-tui-skill-slash-command.md)。
fork 的 skill 上下文(`context: fork`)、参数声明与提示(`arguments` 和 `argument-hint`)、以及逐 skill 的工具约束(`allowed-tools` 和 `disallowed-tools`)不在已交付的约定范围内。注册表、本地提供方和面向模型的工具不解析、不广播、也不强制执行这些字段。直接用户调用曾作为 TUI 功能交付,基于共享调用策略和受信的 `get()` 原语;见[已归档的 TUI skill 斜杠命令](../../archived/feature/2026-07-21-tui-skill-slash-command.md)。

View File

@@ -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/feature/2026-07-08-background-subagent-tasks.md
2026-07-08-background-subagent-tasks.md: 679c57c3173a32b8cfccc6611b4ba74487e7f7e3
2026-07-08-background-subagent-tasks.zh.md: 487943ab5cfe78226cde912854099fc56a5833a6
2026-07-08-background-subagent-tasks.zh.md: ebfbb85add2f73d62b680407a6277c71f01f318e

View File

@@ -8,7 +8,7 @@ Status: implemented
[subagent seam](2026-06-21-subagent-capability-seam.md) 会返回 `SubagentRun`,但原先面向模型的工具会同步收集每一次运行。因此,各自独立的慢速委派要么一直占用父调用,要么按串行方式运行。
subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者资源释放契约。
subagent 需要与其他长时间运行的工具相同的启动、收集、列出、停止、归属、通知和清理行为,但不应采用进程流语义。子会话仍是详细记录;父级只需最终答案和任务状态。后台子级的存活时间还会超过启动它的工具调用,因此必须明确其取消和拥有者 dispose(资源释放)约定。
## 决策
@@ -16,7 +16,7 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列
后台 subagent 使用[通用后台任务运行时](../architecture/2026-06-20-generic-long-running-tool-runtime.md)。`task_output`、`task_list` 和 `task_kill` 负责收集、列出、取消、完成通知和提示词引导;系统不提供 subagent 专用的配套工具。
前台调用保留其同步契约:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前释放该运行。
前台调用保留其同步约定:等待提供方启动和 `run.result`;仅当状态为 `completed` 时返回最终文本;将其他终止原因映射为出错的工具结果;并且始终在返回前对该运行执行 dispose。
对于后台调用,工具会验证父级,并在调用 `ctx.tasks.start()` 前拒绝已中止的执行信号。任务运行时会在调用生产者启动器前,预检控制接口和拥有者清理。该启动器创建独立的 `AbortController` 并启动 `ctx.subagents.start()`;返回 id 之后,工具调用的信号不再拥有该子级。
@@ -24,14 +24,14 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列
- `kind` 为 `subagent`,`label` 为模型提供的描述,`owner` 为父 agent(智能体)。
- `cancel(reason?)` 中止任务自有的控制器。同一个信号同时覆盖尚未完成的提供方启动和已发布 run 的剩余工作。
- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和资源释放失败会转换为失败结果,而不是被拒绝的任务 Promise。
- `done` 等待提供方启动、子级结果和 `run.dispose()`。已完成的运行返回最终文本,已中止的运行变为 `killed`,其他停止原因变为 `failed`。启动、结果和 dispose 失败会转换为失败结果,而不是被拒绝的任务 Promise。
- `readOutput` 不存在。任务存活期间,`task_output` 只返回状态;结算后,它以幂等方式返回最终输出。中间的子级活动仍保留在子会话中。
## 生命周期
后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent 资源释放会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级资源释放,避免泄漏子 agent 和会话。
后台 subagent 归属于其父 agent,不会在拥有者关闭后持久存续。任务运行时将清理附加到对应拥有者的确切作用域。agent dispose 会取消任务,并在 `AgentHandle.dispose()` 完成前等待启动回滚或子级 dispose,避免泄漏子 agent 和会话。
完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经释放了注入目标,该通知将被丢弃;生命周期保证是清理,而不是通知。
完成通知会发送给启动时捕获的确切拥有者。如果拥有者清理过程已经对注入目标执行了 dispose,该通知将被丢弃;生命周期保证是清理,而不是通知。
## 模型引导
@@ -51,14 +51,14 @@ subagent 需要与其他长时间运行的工具相同的启动、收集、列
agent 和日志可能以会话为作用域,但任务注册表和可预测 id 属于运行时全局范围。因此,通用拥有者防线同样适用于 subagent 和所有其他生产者。
### 增量子 transcript 输出
### 增量子 transcript(文本记录)输出
将子级历史以流式方式写入父级,会模糊日志边界,并使提供方行为分化。此接口只公开最终输出;更丰富的观察应由会话或 UI 工具承担。
## 测试
单元测试覆盖固定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方发布前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照覆盖固定了面向模型的 schema。
单元测试锁定了停止原因映射、在报告前释放资源、启动与结果失败、对预中止的拒绝、从启动调用信号分离、在提供方发布前后取消、通过真实任务工具收集、无控制接口的预检防线、运行时缺失失败,以及每实例 schema 开关。快照测试锁定了面向模型的 schema。
## 影响
父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者释放之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。
父级可以并行分派慢速委派任务,并通过与 bash 共用的任务控制来收集结果。子级工作不再占用启动它的工具调用,但在收集、终止或拥有者 dispose 之前可以继续消耗资源。提示词引导鼓励收集;拥有者清理则提供硬性生命周期边界。需要同步委派的部署可以按工具实例禁用后台模式。

View File

@@ -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/feature/2026-07-20-dsh-cli-personal-config.md
2026-07-20-dsh-cli-personal-config.md: c00432ba2b75cc42caf23e405c24acb33d839e7a
2026-07-20-dsh-cli-personal-config.zh.md: b33a7fa321ca04f41e47386544ffc0f268a8b1b6
2026-07-20-dsh-cli-personal-config.zh.md: b2fbd4b68acd92496ca2b087b172504dd93abd88

View File

@@ -4,29 +4,29 @@ Status: implemented
[English](2026-07-20-dsh-cli-personal-config.md) | 中文
## Problem
## 问题
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent,必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
开发者自己的偏好——TUI 使用哪个提供方和模型、个人凭证、私有的适配器路由——除了改动已提交的文件之外无处安放。要把 TUI 示例指向个人的 Anthropic 代理 Opus 路由,只能在工作区里改 `examples/tui-agent/cordis.yml` 和 `.env`,既有提交密钥的风险,又要在每个 checkout 里重复一遍。也没有可安装的命令:想在任意项目目录里运行这个 agent(智能体),必须回到仓库根目录调用示例脚本。Loader 元数据是静态的,所以「条件组合使用 overlay」(AGENTS.md)——但 overlay 此前只以已提交的同级文件形式存在,没有机器级的层。
## Decision
## 决策
下文的各入口模式,以及个人文件的名称与位置,已被 [profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)取代:`dsh` 启动 profile,个人层变成逐 profile 与 home 级的 `cordis.patch.yml`。保留不变的是本笔记的实质:以 Harness home 作为机器级层的根目录、在随附组合之上使用 patch 语义,以及解析时的大声失败。
下文的各入口模式,以及个人文件的名称与位置,已被 [profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)取代:`dsh` 启动 profile,个人层变成逐 profile 与 home 级的 `cordis.patch.yml`。保留不变的是本笔记的实质:以 Harness home 作为机器级层的根目录、在随附组合之上使用 patch 语义,以及解析失败时明确报错。
两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐:
两个耦合的部分,与 `dsh web` PR(Pull Request)#443提出的 `apps/` 装配层对齐:
**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该契约由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`pnpm run demo:tui` 运行同一入口。
**`dsh` CLI(命令行界面,`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`pnpm run demo:tui` 运行同一入口。
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
**个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skill(技能)和 AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动:
- `.env`——在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境变量 > 项目 `.env` > 个人 `.env`。
- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。
- 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误会明确导致启动失败,绝不静默跳过)。
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture(测试前置数据);只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。
TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人配置路径。每次新增、变更或移除都会以事务方式通过启动器自己的组合闭包重新组合完整 patch 列表,因此新的个人 patch 落在启动时相同的层次位置。YAML 无效或 Loader 候选被拒时,最后一个可用树保持活动状态,并广播 `hmr/config-update-failed(filename, Error)`;无头界面只在启动时读取该文件。Include 在已提交配置文件刷新时也会重新应用其 patch(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md))。
TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人配置路径。每次新增、变更或移除都会以事务方式通过启动器自己的组合闭包重新组合完整 patch 列表,因此新的个人 patch 落在启动时相同的层次位置。YAML 无效或 Loader 候选被拒时,最后一棵可用的配置树保持活动状态,并广播 `hmr/config-update-failed(filename, Error)`;无头界面只在启动时读取该文件。Include 在已提交配置文件刷新时也会重新应用其 patch(见[配置热重载韧性 Agent Note](../bug-fix/2026-07-20-config-hot-reload-resilience.md))。
## Alternatives considered
## 曾考虑的替代方案
**另设一个 `bin/dsh` 包装脚本并由其占用 `dsh` 名称。** 否决,因为 `apps/cli` 是统一的产品 CLI,负责分发默认 TUI、无头和 Web 界面。两个相互竞争的入口会在 `$PATH` 和产品身份上冲突。
@@ -34,11 +34,11 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人
**个人完整 `cordis.yml` 去 include 请求的配置。** 否决:个人文件将不得不写死叶子配置的路径,而该路径随 checkout 变化;补丁反转了依赖方向,bin 仍然选择配置树,个人层只做修正。
**把个人补丁深合并进配置项配置。** 否决:会使补丁语义与已提交 overlay 和 vendor 的 include 分叉;整个 `config` 替换已是成文契约。
**把个人补丁深合并进配置项配置。** 否决:会使补丁语义与已提交 overlay 和 vendor 的 include 分叉;整个 `config` 替换已是成文约定。
**用环境变量开关代替存在性判断。** 否决:默认关闭的个人配置永远不会被用起来;存在即生效加上每个测试的显式隔离,让实际运行获得 overlay、测试获得封闭性。
**用环境变量开关代替存在性判断。** 否决:默认关闭的个人配置永远不会被用起来;存在即生效加上每个测试的显式隔离,让实际运行获得 overlay、测试保持隔离。
## Consequences
## 后果
- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。
- 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。
@@ -46,6 +46,6 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人
- `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。
- 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。
## Testing
## 测试
`packages/boot/app-boot/tests/user-patches.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`apps/cli/tests/built-bin.e2e.ts` 启动真实 dsh bin 并基于 profile 端到端验证实时 patch 层。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。
`packages/boot/app-boot/tests/user-patches.spec.ts` 固定解析、启动时应用、确切路径的新增、失败、恢复、移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`apps/cli/tests/built-bin.e2e.ts` 启动真实 dsh bin 并基于 profile 端到端验证实时 patch 层。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。

View File

@@ -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/feature/2026-07-21-continuable-background-subagents.md
2026-07-21-continuable-background-subagents.md: e37abdd798242bc1086754e86a85ef46c08e59a3
2026-07-21-continuable-background-subagents.zh.md: 799e1914bdeb571dc697652081be6b0170768655
2026-07-21-continuable-background-subagents.zh.md: 33bf9a1b7e4568ba0e4b2ea99ac590df9440c93f

View File

@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-21-continuable-background-subagents.md) | 中文
本记录已由[可继续的 subagent](2026-07-28-continuable-subagent-conversations.md)取代——后者以一个持久 Session 加至多一个进程内 Activation(驻留期)替换了其基于 Task 的 activation 模型、路由、取消和持久性语义。其服务放置与提供方功能策略此前已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。仅持久 child 会话与 descriptor 的设计依据仍然有效。
本记录已由[可继续的 subagent](2026-07-28-continuable-subagent-conversations.md)取代——后者以一个持久会话加至多一个进程内 Activation(激活)替换了其基于 Task 的 activation 模型、路由、取消和持久性语义。其服务放置与提供方能力策略此前已由[将 subagent 控制合并到 subagent 服务](../simplification/2026-07-26-merge-subagent-control-service.md)和[以意图命名的 subagent 继续执行操作](../simplification/2026-07-27-intent-named-subagent-continuation-operations.md)取代。仅持久 child 会话与 descriptor 的设计依据仍然有效。
## 问题
@@ -14,9 +14,9 @@ Task、run 和 child 会话具有不同的生命周期。一个 Task 表示一
## 决策
一个可继续的后台 subagent,是由一系列 Task 支撑的短期激活共同组成的持久化 child 会话。child session id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。
一个可继续的后台 subagent,是由一系列 Task 支撑的激活共同组成的持久化 child 会话。child 会话 id、transcript(文本记录)、谱系及声明的组合配置均保留在持久化存储中。每次初始激活或恢复激活都会创建新的 Task、`AgentHandle` 和 `SubagentRun`,驱动一个轮次、收集结果,并在 Task 进入终态前 dispose 该 run。
Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent session id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果:
Task 的结果和取消边界属于 child 激活,不属于为该激活提供第一条消息的调用方。Task 访问根据 parent 会话 id 授权,而 Task 注册表仍保留当前存活的精确 parent Agent 实例,用于通知与资源清理。因此,只要 parent 仍是运行时 owner,parent 消息和用户消息便会共享同一个激活结果:
```text
durable child Session
@@ -25,35 +25,35 @@ durable child Session
activation 3: Task 3 -> SubagentRun -> AgentHandle -> dispose
```
前台委派保持一次性行为。继续执行覆盖后台的进程内 spawn 和 fork child。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 功能,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。
前台委派保持一次性行为。继续执行覆盖后台的进程内 spawn 和 fork child。每个 `tool-subagent` 实例都会选择 `backgroundMode: 'one-shot' | 'continuable'`;配置为可继续模式时,所挂载提供方必须具备 `resume` 能力,而可恢复的提供方仍可采用一次性后台策略。在下述 ACP(Agent Client Protocol)后续工作完成前,ACP child 仍保持一次性行为。
`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Activation 与持久化:它校验提供方功能、解析一次性描述符、分发一个 run、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、可继续描述符持久化与查找、Activation 生命周期,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器自行组合 child 之前,提供方通过私有闭包提供准备数据。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举、一次性/可继续模式共享的描述符身份与 `list_agents` 属于[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)。
`ctx.subagents` 是唯一的公开服务。普通 `start` 不感知 child 集合、Activation 与持久化:它校验提供方能力、解析一次性描述符、分发一个 run、观察 run 生命周期,并返回由持有方负责的 run。注入的内部继续执行管理器负责管理稳定的 child id、可继续描述符持久化与查找、Activation 生命周期,以及通过 `startContinuable` 和 `followup` 进行的路由;管理器自行组合 child 之前,提供方通过私有闭包提供准备数据。按提供方绑定的 `@deepseek-ai/dsh-tool-subagent` 插件及面向用户的适配器调用这些意图操作来处理可继续后台工作;前台和一次性后台委派使用普通 `start`。全局命名的模型工具是 `@deepseek-ai/dsh-tool-subagent-control` 中的可选轻量适配器,它是否存在不会决定是否启动可继续工作。parent 到 child 的枚举、一次性/可继续模式共享的描述符身份与 `list_agents` 属于[持久化 subagent 目录](2026-07-22-durable-subagent-catalog-and-list-agents.md)。
### Task 与取消的所有权
初始后台委派请求 `ctx.subagents` 启动 child 并注册其 Task。可继续提供方只有在确认本次激活的最终会话状态已持久化后,才会返回成功的 run 结果。Task 结算流程等待该结果,通过继续执行管理器的结算路径调用 `run.dispose()`,然后才记录 `TaskOutcome`;`task_kill` 中止活跃 run,其结算路径仍会 dispose 该 run。因此,终态 Task 会留下持久化 child 会话,但不会留下存活的 child agent。必需的持久性检查点若没有已安装的监听器或任一监听器失败,run 会以稳定错误码 `DURABILITY_FAILED` 拒绝,并将检查点失败保留为失败原因;管理器会记录失败的 Task,其详情说明最新状态未确认已持久化,因此恢复时可能不可用或已陈旧。
后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 契约,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。
后续每个轮次都会创建另一个 Task。该轮 producer 持有的执行资源仅服务于这次激活,不属于 child 会话。它只会到达一次终态、只产生一个结果,也不会重新打开。Task 注册表中当前注册的那个存活 parent agent 实例仍是其 owner:dispose 该实例会取消、等待并移除其 Task。Task API 会授权 session id 与该 owner 匹配的调用方,但 id 相同的替代实例不会成为通知或资源清理目标。这一设计保留 `settleRun()` 约定,并使 Task 所拥有的存活 child 数量受并发工作量限制,而不是随历史会话数量增长。
用户界面适配器打开 child 会话时,只读取持久化 transcript,不会仅为展示而恢复 agent。用户输入通过继续执行管理器,启动或加入与 parent 输入相同的 Task 激活。由用户启动的 Task 会保留当前加载的精确 parent Agent 作为通知目标,`task_output` 仍是唯一结果路径。只要 Task 尚未标记为已报告,现有完成监听器最多注入一条主动通知;`kill`、终态读取或终态等待都可能将其标记为已报告,并抑制这条通知。因此,仅允许在该 parent 实例保持存活时进行用户交互。可以比 parent 存活更久、并将结论显式合并回去的用户自有会话属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md),不属于这一由 Task 持有的生命周期。
如果没有附加 Task 控制面,`TaskService.start()` 会拒绝 producer。因此,接受 child 输入的用户界面适配器必须附加 Task 控制面,或运行于加载了 `@deepseek-ai/dsh-tool-tasks` 的部署中;仅加载 Task 服务并不足够。SDK 生成的 spawn 与 fork 组合在挂载 subagent 工具时,也会挂载 `@deepseek-ai/dsh-tasks-local` 与 `@deepseek-ai/dsh-tool-tasks`。这项依赖是 parent 和用户启动的激活共用 Task 结果、取消和通知路径所付出的代价。
取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering 正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。
取消始终作用于当前完整激活。如果用户消息和 parent 消息已经加入同一个轮次,任一调用方发起取消都会中止该轮次、dispose 其 run,并将对应 Task 结算为 `killed`;这些消息没有独立的结果或取消权。`followup()` 要求调用方提供信号;若在线 steering(中途引导)正在等待请求准入时该信号被中止,激活自有的 controller 会被中止,以便提供方丢弃待处理消息,并且该调用仅在子 agent 完全停稳后结算。若需要独立取消,后续消息必须另起轮次,而不能加入当前轮次。
从持久化存储恢复的 Task 会在查找描述符或等待任何提供方操作之前,创建由本次激活持有的 `AbortController`;描述符查找、直接 parent 鉴权和描述符归并都在该 Task producer 内部执行,因此同一信号覆盖它们,其失败会将该 Task 结算为 `failed`。对于不接受信号的持久化调用,可以让底层 I/O 执行完毕;但继续执行管理器必须在每次这类 await 返回后重新检查取消状态,如已取消,之后不得开始或发布任何 child 工作。在 Agent 发布前收到中止信号时,提供方必须先回滚其创建事务并达到完全停稳状态,然后才让恢复调用以拒绝结束。Agent 发布后,提供方必须消除创建期间移交取消信号时的竞态,在返回前将同一信号附加到存活 run;之后取消会停止 child 轮次。即使提供方的恢复调用尚未返回 `SubagentRun`,`task_kill` 与对确切 owner 实例的 dispose 仍通过这条路径生效。Task 结算会等待回滚或 run dispose 完成,只有在激活完全停稳后才记录 `killed`。
### 活跃 run 关联
继续执行管理器在进程内维护 child session id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。
继续执行管理器在进程内维护 child 会话 id 到当前 Task 的关联,并在提供方发布后将 run 填入该关联。它会在等待提供方 start 或 resume 之前安装 Task 关联,填入返回的 run,并且只在 run dispose 完成且 Task 终态发布后才移除该关联。该关联只用于让 parent 发送方和用户发送方找到同一次激活;它不是持久化 child 目录、公开的 `ManagedSubagent`、准入预留或 run 状态机。
对于可继续 child 的初始激活,继续执行管理器会在创建 Task 前分配稳定的 child session id,并将其作为 `SubagentProviderStartRequest.continuation` 传递;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。普通 `SubagentStartRequest` 不含 continuation 字段。后台工具返回规范的 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent <childId> as task <taskId>`。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。
对于可继续 child 的初始激活,继续执行管理器会在创建 Task 前分配稳定的 child 会话 id,并将其作为 `SubagentProviderStartRequest.continuation` 传递;进程内 spawn 和 fork 会发布这一确切 id,而不是在内部另行分配。普通 `SubagentStartRequest` 不含 continuation 字段。后台工具返回规范的 `{ kind: 'background', taskId, subagentId }`,渲染为 `started subagent <childId> as task <taskId>`。child id 在多次激活中始终指代同一个持久化对话,Task id 则只指代当前激活。初始 Task 失败,或进程在 child 首次 flush 之前退出,都可能留下一个 **unmaterialized child**:调用方持有 child id,但不存在持久化 header 和描述符。后续按 id 的操作会报告该 id 不可用(已启动的 Task 会带着该详情失败),持久化枚举也不会列出它。
每个可继续 child 轮次都通过这条由 Task 支撑的路径准入。非终态 Task 是唯一受支持的存活激活;不存在激活时,其 run 已被 dispose,持久化 child 可以恢复。在路由任何按 id 的操作之前,继续执行管理器会同步将自身关联与 `ctx.agents.get(childId)` 比较。如果注册表中的 Agent 没有关联,或者它与所关联的 `run.localAgent` 不同,就属于所有权冲突:管理器会失败,而不会接管 idle Agent 或附加未受跟踪的轮次。二者均不存在时,可以从持久化存储恢复;如果检查后又有竞争方发布,仍会在 Agent 注册表的冲突边界上失败。
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 功能接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/step` 与异步提示词组装成功后,系统追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose(资源释放)会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
系统依据 Task 关联进行路由。运行中的 Task 通过 run 可选且提供确认语义的 `SubagentRun.steer` 能力接收在线消息。Task 不存在时,系统创建新 Task,并从持久化存储恢复 child。进程内 spawn 和 fork 会先同步要求 child 处于 `running` 状态,并拒绝已经提交结构化捕获的 child;随后调用 `Agent.steer()`,等待该消息专属的准入回执。默认循环会为每个 steering 项目提供一份归属于该消息的回执;只有在 `agent/step` 与异步提示词组装成功后,系统追加该消息、捕获不可变的请求历史并提交 `step/start`,回执才会解析为 `admitted`。终止型轮次策略、取消和 dispose 会将待处理回执解析为 `rejected`。非终止型轮次关闭可以把待处理 steering 带入后续排队轮次,但不会确认其准入。提供方必须在调用 `Agent.steer()` 前检查存活状态,避免其 idle 路径在观察到的 run 之外启动轮次。如果查找关联之后、请求获准之前,Task 结算或终止策略率先完成,`steer()` 会拒绝,`send_message` 会报告消息未送达,而且该次调用不会改用从持久化存储恢复路径;在 Task 终态发布后重试,才可能启动下一次激活。
继续执行管理器不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 只准入一次激活——resume 加载期间竞争的 `followup` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。
继续执行管理器不会串行化两个通过其外部路径同时争抢已停止 child 的调用方,也不会为结果产生与 dispose 之间的阶段单独建立 settling 状态。在 producer 首次 await 之前同步安装的关联,使本进程内每个 child 一次只准入一个激活——resume 加载期间竞争的 `followup` 会观察到待处理的激活并显式失败——而绕开该关联的发布仍会在 Agent 注册表相同会话的冲突边界上失败。发送也可能因与启动、取消、完成或清理发生竞态而失败。这些限制是明确的,而非隐藏在更大的生命周期抽象之后。
### 面向模型的 `send_message`
@@ -67,25 +67,25 @@ durable child Session
发送到现有 run 的消息没有独立结果,其效果体现在当前 Task 的最终结果中。启动的后续轮次具有新 Task 的结果,并使用现有 `task_output` 读取路径。subagent 层不会再注入第二份完成通知。
用户输入使用同一个 `followup` 操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个服务契约,不建立彼此独立的执行路径。
用户输入使用同一个 `followup` 操作。UI 可以展示 child transcript 和当前 Task 状态,取消操作则以已加载 parent 作为调用方访问 Task 服务。工具 schema 与 UI 适配器消费同一个服务约定,不建立彼此独立的执行路径。
### 持久化 child handle 与从持久化存储恢复
继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。
继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩(compaction)替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。
版本化描述符的可继续分支([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)携带 `mode: 'continuable'`、subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果契约,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。
版本化描述符的可继续分支([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)携带 `mode: 'continuable'`、subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果约定,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。
从持久化存储恢复不能依赖 `SubagentRun` 的可选方法,因为该 run 已被 dispose,并且进程重启后不会保留。run 表示一次可 dispose 的激活,只暴露作用于当前激活的操作。`SubagentRun.steer?()` 这一名称明确指代提供确认语义且仅适用于在线消息的功能,以免该功能与服务编排或面向模型的工具混淆。
内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方功能后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。
内部继续执行管理器的恢复路径会加载已知 child 会话、归并其描述符、根据持久化的 `parentSession` 鉴权,并在其创建的 Task 内部运行。它通过私有服务闭包传递完全解析的 `SubagentProviderResumeRequest`,其中包含由 Task 持有的取消信号;该闭包只负责在检查提供方能力后进行分发,并执行 `start` 所使用的普通 run 生命周期观察。选中的 `SubagentProvider.resume?()` 负责传输相关的重建(进程内:在当前加载的 parent 作用域下执行 `parent.ctx.agents.resume`),并返回一个新 run。提供方是否存在该方法本身就是继续执行功能,无需额外功能标志。`SubagentService.followup()` 在关联 run 的 `steer?()` 操作与该持久化恢复路径之间做出选择。私有的提供方分发与提供方本身都不会枚举持久化 child 或关联 Task。
后台工具会在调用 `TaskService.start()` 前校验描述符输入并建立快照。同步校验失败会拒绝工具调用,且不会创建 Task。除此之外,工具会立即返回 child id 和 Task id,不等待 child 发布或描述符持久化完成。进程内可继续提供方会在 child 进入 idle 后、读取结果之前执行最终的 `SessionStore.flush()`;返回 `true` 表示至少有一个持久性监听器参与,返回 `false` 表示必需的检查点失败,而拒绝则携带监听器失败。此操作会在 child 仍存活时重试循环中失败的检查点。如果最终确认失败,提供方会拒绝而不返回未经确认的输出,继续执行管理器会 dispose 该 run,已经创建的 Task 会结算为 `failed`,其详情包含持久性诊断。最终确认期间发生取消时,尚未发布的激活结果由取消操作接管;即使 child 轮次已记录为完成,或之后的检查点失败,也不能取代 Task 的 `killed` 结果。前台一次性运行仍保留循环仅尽力执行检查点的行为。进程内 spawn 和 fork 会在当前已加载的 parent 作用域下重建组合配置。恢复 fork 时只加载 child 自己的持久化 transcript,其中已经包含初始创建时捕获的已完成轮次前缀;系统绝不会再次 fork parent 更新后的历史。恢复 parent 不会立即恢复其 child。
TODO(ACP 继续执行):将远端 ACP session id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。
TODO(ACP 继续执行):将远端 ACP 会话 id 作为提供方专用描述符数据持久化,并实现 `AcpProvider.resume?()`,依次执行 spawn、initialize、`loadSession` 和 prompt。初始 ACP run 必须检查 `initialize.agentCapabilities.loadSession`,恢复后的每个进程必须使用同一个持久化后端;`loadSession` 回放的历史消息不得计入新激活的输出。由于 ACP 的加载支持是按 child 协商的,不能仅根据提供方是否存在该方法来确定,因此该后续工作还必须定义 start 结果如何声明单个 child 支持继续执行,之后才能将 ACP child 写入持久化目录。
### 结果与通知所有权
每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告契约最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。
每次可继续 child 激活都恰好拥有一个 Task 和一个 `TaskOutcome`,无论第一条消息由 parent 还是用户提供。只要 Task 尚未标记为已报告,通用 Task 报告约定最多会向保留的 parent owner 注入一条主动完成通知;读取、等待和取消都可能抑制该通知。发送到运行中激活的消息会加入该激活,不会创建第二个 Task 或第二份结果。child transcript 是面向用户的详细记录;Task 输出是面向 parent 的最终结果。
Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可在重启后恢复,但不会恢复中断的 Task、其结果或通知。持久化 Task 恢复属于另一个问题。
@@ -95,7 +95,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
**允许用户轮次不使用 Task。** parent 消息加入此类轮次后,没有对应的 Task 结果或完成通知;UI 取消对 parent 所发消息的影响也不明确。让每次激活都拥有一个 Task,可使完成与取消成为 child 轮次的属性,而不是初始调用方的属性。
**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 契约。
**在 child 会话整个生命周期内复用一个 Task。** 终态 Task 无法自然地再次进入运行状态,一个结果也无法表示多个轮次。每次激活创建新 Task 可以保留通用 Task 约定。
**为每条消息创建 Task。** 发送到现有 run 的消息会加入已有轮次,不产生独立的最终结果;为这类消息创建 Task,会重复当前 Task,或报告一个它并不拥有的结果。只有启动新激活的消息才会创建 Task。
@@ -109,7 +109,7 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
## 测试
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述契约。
- `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 固定可继续执行的持久性边界:缺少 flush 监听器、flush 监听器已脱离或监听器持续失败时,均会以 `DURABILITY_FAILED` 拒绝;循环检查点的瞬时失败可在最终确认成功后继续完成,发生取消时最终检查点无论成功还是失败都由取消优先决定结果,resume 同样会确认持久性,而前台运行仍采用尽力而为策略。`packages/subagent/subagent/tests/continuation.spec.ts` 以无密钥方式驱动真实栈(agent loop(智能体循环)、JSONL 持久化、spawn/fork 提供方、Task 服务和 `ctx.subagents`):初始及恢复后的激活都会创建新 Task,并在进入终态前 dispose 各自的 run;描述符事件位于轮次前、对模型隐藏、带版本、在服务分配的 child id 下持久化,并在初始 prompt admission 阻止请求或抛出异常时仍保留;取消、steering、cold follow-up、授权、所有权冲突与 resume 竞态保留上述约定。
- `packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts` 固定 `send_message` 的 schema、coordinator 来源标记、两种路由渲染、未送达失败、无 agent 时的拒绝,以及 HMR(热模块替换)dispose。
- `packages/subagent/tool-subagent/tests/tool-subagent.spec.ts` 覆盖配置的后台路由:可继续模式要求提供方可恢复,并在不要求 `send_message` 的情况下返回两个 id;即使提供方可以恢复,一次性模式仍保持普通的 Task 确认消息。
- `packages/scaffold/helper/tests/project.spec.ts` 固定生成的 spawn 与 fork 组合中的 Task 服务及面向模型的 Task 控制工具。
@@ -123,5 +123,5 @@ Task 记录和活跃 run 关联都位于进程内。持久化使 child 会话可
- 活跃 run 关联只能协调一个运行时。多个进程同时恢复时不会串行化;此类部署需要持久化层的租约或 compare-and-set 操作。
- 用户交互要求作为 owner 的那个精确 parent Agent 实例保持存活,因为 dispose owner 会取消并移除其 Task。用户交互还要求附加 Task 控制面。若要单独与 child 交互,后续必须将 Task 访问所有权与持久化通知目标分离。
- 后台工具会在 child 发布和描述符持久化之前返回 child id 和 Task id。启动失败、最终持久性确认失败,或进程在 child 首次 flush 之前退出,都会使 Task 失败,并可能留下 unmaterialized 或陈旧的 child id;按 id 的控制操作会将缺失状态报告为不可用,而不会追溯修改工具确认消息。
- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性契约便成为恢复契约的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。
- 将显式组合字段持久化到 child 日志后,其无损 JSON 与兼容性约定便成为恢复约定的一部分。后续如需支持其他组合配置输入,必须明确更改描述符版本,不能隐式持久化可通过声明合并扩展的 `AgentOptions` 字段。
- Task 记录和活跃 run 关联位于进程内,而 child 会话具有持久性。重启会恢复会话,但不会恢复进行中的工作或其 Task 通知。

View File

@@ -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/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
2026-07-22-durable-subagent-catalog-and-list-agents.md: 61bda2a52f2ea5db0a582fe668643a13b967f091
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: a2bc36200d8c67b8da9dd3d55ea4179baa72e691
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: fb60e63f23fae3146963e0fe4a7df44d0b497281

View File

@@ -1,4 +1,4 @@
# Agent Note(agent 决策记录):持久化 subagent 目录与 list_agents
# Agent Note: 持久化 subagent 目录与 list_agents
Status: implemented
@@ -8,11 +8,11 @@ Status: implemented
可继续的后台 subagent 会公开稳定的 child id,并将重建数据持久化在该 child 的会话中,因此 `send_message` 无需任何列表查询操作即可恢复已知 child。发现功能有两类需求不同的消费方:UI 可以同时展示一次性工作和可继续对话,而模型只应收到适合使用 `send_message` 的 child。[可继续 subagent](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md)负责持久化 Session 与 Activation 设计;本记录负责共享的持久化清单及面向模型的投影。
枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。追踪谱系可以提供候选项,却无法区分普通会话 fork 与 subagent,因此 child 日志需要持久化分类。契约还必须定义生命周期模式、缺失或损坏的记录、删除、不受支持的版本,以及反复加载大量 child 日志会如何影响服务与工具消费方。
枚举必须交叉核对不可变的会话谱系、描述符有效性与实时优先的会话语料,而不能仅为展示就加载或恢复 Agent。追踪谱系可以提供候选项,却无法区分普通会话 fork 与 subagent,因此 child 日志需要持久化分类。约定还必须定义生命周期模式、缺失或损坏的记录、删除、不受支持的版本,以及反复加载 child 日志会如何影响服务与工具消费方。
## 决策
**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。
**列表读路径已被取代。**[subagent 列表经投影单元读取身份](../architecture/2026-08-06-subagent-list-identity-projection.md)取代了本记录的枚举与逐 child 读取设计:`listChildren` 现在直接合并存活会话存储与可选的会话持久化,并从注册的 `subagent` projection unit 读取每个 child 的 mode/label——不依赖会话查询,也不在列表时扫描描述符;当前的列表语义(含 diagnostic 映射)以该记录为准。本记录仍是描述符持久化、以 mode 判别的描述符作为持久身份、直接 parent 鉴权与面向模型的 `list_agents` 投影的权威;下文基于追踪的读取机制是决策背景,不再是当前行为。
parent 到 child 的枚举是一项带消费方专用投影的服务功能。`SubagentService.listChildren(parentSessionId: SessionId)`([subagent/src/index.ts](../../../../packages/subagent/subagent/src/index.ts))执行以下操作:
@@ -23,27 +23,27 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
- 将语料活动状态单独报告为 `running` 或 `inactive`,但不暗示已完成或可恢复;
- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`);UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`);UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 约定负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
### 枚举决策
第一版消费 `ctx.sessionQuery.traceSession(parentSessionId)`,并且只考虑追踪结果的第一层后代。目标可以存活,也可以只存在于持久化存储中;追踪逻辑语料不会加载或恢复 Agent。会话查询已经使用实时优先规则合并 `ctx.sessions` 与 `ctx.sessionPersistence`,保持不可变 header 一致性,根据 `SessionHeader.parentSession` 推导直接 child 谱系,并按 `createdAt` 升序、child id 升序排列 sibling。`listChildren()` 不会重复实现这套语料逻辑,也不会检查继续执行管理器的进程内 Activation map。
语料构建先于逐 child 描述符检查。构建初始追踪时如果发生持久化列表查询失败、所观测语料中任意位置的存活/持久化 header 冲突或目标谱系无效,整个 `list_agents` 调用都会失败,因为此时不存在可信的候选集。只有初始追踪成功后的失败才会被隔离到单个候选;因此,这项逐 child 契约中的“损坏 child”是指已加载的事件 surface 或描述符数据损坏,而不是语料级 header 冲突。
语料构建先于逐 child 描述符检查。构建初始追踪时如果发生持久化列表查询失败、所观测语料中任意位置的存活/持久化 header 冲突或目标谱系无效,整个 `list_agents` 调用都会失败,因为此时不存在可信的候选集。只有初始追踪成功后的失败才会被隔离到单个候选;因此,这项逐 child 约定中的「损坏 child」是指已加载的事件 surface 或描述符数据损坏,而不是语料级 header 冲突。
会话谱系涵盖的范围比 subagent 身份更广:普通 `ctx.sessions.fork()` 也会创建直接 child。会话 header 不新增 `kind` 判别字段;每个候选必须改为在自身后缀中恰好包含一个有效的 `subagent/descriptor` 事件。`SubagentService.start()` 会在普通提供方分发前解析 `{ mode: 'one-shot', provider, label? }`,而继续执行管理器会在初始创建 child 时为 `{ mode: 'continuable', ...composition }` 建立快照并将其作为 seed。本地进程内的一次性驱动只在初始创建期间追加已解析的描述符,从持久化存储冷恢复时不会追加其他描述符;第二个事件属于损坏,而不是另一次 Activation 的证据。Agent 创建是一次性运行的发布边界:拒绝表示没有发布 child,而发布后的提示词、轮次、取消与基础设施结果会通过返回的 run 结算,且不会隐藏其 id。描述符事件是已追踪 child 属于由会话支撑的 subagent 的唯一证据。缺少该事件的候选属于普通 fork、没有本地会话记录的远端 child 或其他非 subagent 会话,系统会将其排除且不产生 diagnostic。
已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose(资源释放)或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时契约。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准。
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentService` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.md)为准。
`listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。
这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选都会执行 `sessionQuery.listEvents(childId)`。没有描述符的候选会被排除;含有多个描述符的候选会直接产生 diagnostic,无需再次读取;只有恰好含有一个描述符的候选才会通过 `sessionQuery.readEvent({ sessionId: childId, seq })` 再次加载。此次读取返回的不可变会话 header 必须与追踪时观测到的相同,包括直接 parent 关系,并且读取目标仍必须是先前定位的描述符事件;任何不一致均视为该 child 损坏。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 `persistence.list()`、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);恰好含有一个描述符的候选承担两次这类成本,其他候选只承担一次。存活候选同样会对其完整日志取得一份分离的内存快照;读取其描述符时则会取得两份。会话查询通过持久化 seam 的非变更 `inspect()` 读取解析持久化候选:它返回有效的已存储前缀,既不修复撕裂的尾部,也不关闭中断的 turn,因此列表查询是存储只读操作;修复仍是恢复路径的职责。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会追加任何目录、描述符或修复事件。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。
这条描述符读取路径是正确性基线,并不声称工作量只与直接 child 数量呈线性关系。令 D 为直接 child 候选数量,C 为每次持久化列表查询所扫描的持久化会话数量,L_i 为候选 i 的完整日志大小。一次语料追踪后,每个候选都会执行 `sessionQuery.listEvents(childId)`。没有描述符的候选会被排除;含有多个描述符的候选会直接产生 diagnostic,无需再次读取;只有恰好含有一个描述符的候选才会通过 `sessionQuery.readEvent({ sessionId: childId, seq })` 再次加载。此次读取返回的不可变会话 header 必须与追踪时观测到的相同,包括直接 parent 关系,并且读取目标仍必须是先前定位的描述符事件;任何不一致均视为该 child 损坏。对于只存在于持久化存储中的最坏情况,每次精确读取都会重复执行 `persistence.list()`、加载完整 child 日志并克隆其中的事件,因此忽略常数因子后的工作量为 O(D × C + Σ L_i);恰好含有一个描述符的候选承担两次这类成本,其他候选只承担一次。存活候选同样会对其完整日志取得一份分离的内存快照;读取其描述符时则会取得两份。会话查询通过持久化 seam 的非变更 `inspect()` 读取解析持久化候选:它返回有效的已存储前缀,既不修复撕裂的尾部,也不关闭中断的轮次,因此列表查询是存储只读操作;修复仍是恢复路径的职责。第一版接受这些重复读取,将其作为无索引的正确性基线,但部署必须将语料总量和 child 日志大小,而不仅是直接 child 数量,视为容量约束。列表查询不会创建 Agent,也不会追加任何目录、描述符或修复事件。对模型隐藏的描述符始终位于对话 surface 之外,并且会在压缩(compaction)后保留,因此经过压缩和未经压缩的 child 必须枚举出相同结果。
如果实测规模日后需要索引,该索引属于派生状态:会话 header 和 child 描述符仍是权威信息,重建或损坏回退必须复现相同结果。索引不能成为第二个鉴权来源,也不能让尚未发布的 child 变得可见。
### `list_agents` 契约
### `list_agents` 约定
`SubagentService.listChildren(parentSessionId: SessionId)` 返回 `Promise<SubagentListEntry[]>`,其中的单个数组不会将 child 与 diagnostic 分开,而是保留追踪结果中的候选顺序。`SubagentListEntry` 是一个由只读 `kind` 判别的封闭联合类型:
@@ -52,7 +52,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。
面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(`undefined` → `children`)。解析后的 `children` scope 调用 `SubagentService.listChildren(rootSessionId)`,`descendants` scope 则调用 `SubagentService.listDescendants(rootSessionId)`。其内部输出投影中的 `id` 与 `parent` 会一直保持为品牌化的 `SessionId` 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `complete`——然后按稳定目录顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。`descendants` scope 从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent,依据枚举生命周期重新校验每个冷候选,并为每个条目附加 `parentId`/`depth`。工具会在 label 之前插入 ` parent=<id> depth=<n>`;`parent` 是持久化直接 parent 会话 id,可能指向被省略的普通会话。对于当前调用方,只有 depth-1 child 条目可作为 `send_message` 候选,更深的 child 条目则可供 `interrupt_agent` 选择([中断契约](2026-08-06-continuable-subagent-interrupt.md))。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent,中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 `(no subagents)`。
面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(`undefined` → `children`)。解析后的 `children` scope 调用 `SubagentService.listChildren(rootSessionId)`,`descendants` scope 则调用 `SubagentService.listDescendants(rootSessionId)`。其内部输出投影中的 `id` 与 `parent` 会一直保持为品牌化的 `SessionId` 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `complete`——然后按稳定目录顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。`descendants` scope 从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent,依据枚举生命周期重新校验每个冷候选,并为每个条目附加 `parentId`/`depth`。工具会在 label 之前插入 ` parent=<id> depth=<n>`;`parent` 是持久化直接 parent 会话 id,可能指向被省略的普通会话。对于当前调用方,只有 depth-1 child 条目可作为 `send_message` 候选,更深的 child 条目则可供 `interrupt_agent` 选择([中断约定](2026-08-06-continuable-subagent-interrupt.md))。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent,中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 `(no subagents)`。
在已被取代的追踪读路径中,diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。
@@ -80,23 +80,23 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
**持久化 parent 会话目录事件。** 直接 child header 已经提供持久化枚举种子,child 描述符则是重建的权威信息。第二份 parent 日志会重复状态,并造成跨会话顺序和陈旧条目行为,却无助于按 id 恢复。
**要求每次底层启动都提供显示标签。** 这会保证 UI 文本一致,却会把展示关注点引入 workflow、传输、测试和程序化启动契约。底层请求保持标签可选;高层委派与继续执行 API 在本就拥有该概念时提供标签,UI 消费方则为无标签的一次性 child 选择回退展示。
**要求每次底层启动都提供显示标签。** 这会保证 UI 文本一致,却会把展示关注点引入 workflow、传输、测试和程序化启动约定。底层请求保持标签可选;高层委派与继续执行 API 在本就拥有该概念时提供标签,UI 消费方则为无标签的一次性 child 选择回退展示。
**某个 child 无法加载时让整次列表查询失败。** 这种做法不会让损坏问题被忽略,但一个损坏的 sibling 会让每个健康 child 都不再可见。逐 child diagnostic 在保持每次排除明确可见的同时,也保留了发现能力。
**分别返回 child 和 diagnostic 数组。** 分离的数组会引入两个排序域,或者要求公开另一个排序键才能重建候选顺序。一个带判别字段的条目数组既能保留追踪顺序,也能保证 child 与 diagnostic 字段的类型安全。
**通过会触发修复的 `load()` 路径读取候选。** 复用恢复路径的 `load()` 语义可以让发现提前持久化地关闭中断尾部,但会把列表查询变成变更操作,并使其失败模式与写协调耦合。会话查询的语料读取本就使用非变更的 `inspect()` 契约,因此列表查询保持存储只读,尾部修复留给真正需要它的恢复路径。
**通过会触发修复的 `load()` 路径读取候选。** 复用恢复路径的 `load()` 语义可以让发现提前持久化地关闭中断尾部,但会把列表查询变成变更操作,并使其失败模式与写协调耦合。会话查询的语料读取本就使用非变更的 `inspect()` 约定,因此列表查询保持存储只读,尾部修复留给真正需要它的恢复路径。
**立即为查询分页或设置上限(暂缓)。** 这可以限制一次结果的大小,但会使模型发现成为有状态操作,而且除非模型继续跟随 cursor,否则可能隐藏更早的 child。第一版没有 cursor、分页参数或候选数量上限配置,而是返回经稳定排序的完整集合;如果实测规模需要限制,服务级限制仍留待后续决策。
## 测试
- `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents` 但不再注入 `sessionQuery` 的加载契约,以及 HMR dispose。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents` 但不再注入 `sessionQuery` 的加载约定,以及 HMR dispose。
- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。
- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的已结算 child 以 `corrupt` diagnostic 出现。
- 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。
## 影响

View File

@@ -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/feature/2026-07-23-web-assistant-markdown.md
2026-07-23-web-assistant-markdown.md: ad86559e3b5294b6bd67d69ff5c6ce37a5172008
2026-07-23-web-assistant-markdown.zh.md: b47375db843ea1c81b913e3f7ac8cd1bbc278830
2026-07-23-web-assistant-markdown.zh.md: d1f06b4019812b0fee3c481efecaf214a137c8a7

View File

@@ -12,7 +12,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性契约)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。
@@ -26,15 +26,15 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 考虑过的替代方案
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。*
**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要仅接受字符串的包装层无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。*
**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
**将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。
**通过净化启用原始 HTML。**原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。
**移植 deepsuite 的 Prism `highlight.css` 与 mdast 流水线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 约定。
**为处理 CJK 标点边界而预处理 Markdown 源文本,或在解析后修复文本节点。**源文本重写必须在解析器掌握这些区别之前复现转义、代码、数学公式与定界符规则;文本节点修复则已经丢失部分源文本意图,也无法与已解析的行内节点组合。在分词器边界扩展 attention 可保留上游 resolver,并将差异限制在定界符的适用条件上。

View File

@@ -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/feature/2026-07-23-web-permission-and-approval.md
2026-07-23-web-permission-and-approval.md: 06e184fe32ac684e3d8b0820d915ca7c372a5760
2026-07-23-web-permission-and-approval.zh.md: b74e93006cb95c398eb464a9bd9a59e8c9cc57be
2026-07-23-web-permission-and-approval.zh.md: 9c8718169b720f55065d5e4a416f65c94f83cc9c

View File

@@ -6,28 +6,28 @@ Status: implemented
## 问题
Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。
Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议约定其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。
## 决策
Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。
`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。
`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题生成一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是约定早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,调用应答者完成应答,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。
权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/pre-step` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。
权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的权限选项列表,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/pre-step` 时统一应用,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。
在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪以连接代次为单位清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。
在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 约定中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。pending 问题通过 ui-question 接管 composer,包括 `plan-review` 决策形状。侧边栏用一枚优先级高于运行中圆环的琥珀色警示圆点,同步呈现每个被阻塞的交互,搜索期间也不例外:manager 跟踪每个会话的审批与问题请求标识,而非读取 Session 实例;它只把满足 plan-review composer 二元呈现约束的请求分类为计划审查,并在问题与审批并发时优先呈现第一个 pending 问题,以匹配 composer 路由。实例化前的缓冲会保留每个仍有效的请求标识,替换回放产生的重复项,并移除已解决的请求,因此侧边栏状态绝不会比可应答的 `PendingWait` 存续得更久;跟踪以连接代次为单位清除,以保证重开后的回放才是权威依据。从未实例化过的会话仍会点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。
## 曾考虑的替代方案
**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。
**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 约定的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。
**用一个会话事件承载 pending 交互,而非实时注册表。** 不予采纳:可应答请求是瞬态的交互状态,而非持久的会话数据——审批的 `approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。
**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。
**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会导致重载窗口期间发起的 ask 以默认拒绝的方式失败。
**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。
**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因回执被拒或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。
## 后果
Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-question 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审阅与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖率:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。
Web 会话从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答使用同一注册表模式(ui-question 基于问题 pending 表),Session 导航会在用户打开会话前识别审批、计划审查与普通问题等待。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。测试覆盖:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件、针对 fixture 模式审批应答与预设切换的无密钥 Web 冒烟测试,以及真实组合的 plan-review 与问题快照;这些快照会固定 pending 侧边栏状态直至解决。

View File

@@ -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/feature/2026-07-24-model-facing-session-query-tools.md
2026-07-24-model-facing-session-query-tools.md: 863f557f11f89ff8dfc121b7da0b653852528394
2026-07-24-model-facing-session-query-tools.zh.md: d8deaba15f111537a16deafe73ed6dd708ea044a
2026-07-24-model-facing-session-query-tools.zh.md: 584733de7b00f22f186838b058a24c31ff035905

View File

@@ -6,13 +6,13 @@ Status: implemented
## 问题
统一的 `ctx.sessionQuery` 服务对优先使用实时数据的会话日志提供精确读取、过滤、关系追踪与全文搜索,但模型无法直接使用该服务。若把提供方请求类型交给模型,还会暴露不稳定的分页游标、受信任的语料范围、存储形态的时间值,以及更适合程序化消费者而非模型推理的结果记录。大型追踪与事件负载另有输出大小问题,但若在该消费者内部解决,就会重复 harness 的通用 spill 机制,并使会话查询工具与其他工具的行为不一致。
统一的 `ctx.sessionQuery` 服务对优先使用实时数据的会话日志提供精确读取、过滤、关系追踪与全文搜索,但模型无法直接使用该服务。若把提供方请求类型交给模型,还会暴露不稳定的分页游标、受信任的语料范围、存储形态的时间值,以及更适合程序化消费方而非模型推理的结果记录。大型追踪与事件负载另有输出大小问题,但若在该消费方内部解决,就会重复 harness 的通用 spill 机制,并使会话查询工具与其他工具的行为不一致。
## 决策
`@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。
`@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费方。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。
该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包契约。
该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包约定。
`session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。
@@ -20,7 +20,7 @@ Status: implemented
## 工作区权限
每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。
每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent(智能体)身份时按失败关闭处理。
每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。
@@ -28,11 +28,11 @@ Status: implemented
## 无游标结果与 spill
两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。
两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop(智能体循环)调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。
追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。
追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列举操作;持久化事件追踪与读取则将其传递给目标列举与检查操作。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不透明定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费方既不导入 `ctx.spillStore`,也不实现第二套截断格式。
会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。
会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列举与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。
## 宿主组合
@@ -42,14 +42,14 @@ Status: implemented
- **向模型公开提供方游标**:不予采纳,因为记录工具结果或开始下一个模型步骤会改变相关会话或全局代,导致游标通常在模型能够复用前就已过期。
- **增加工具本地截断、偏移量或 spill 文件**:不予采纳,因为执行后 spill 策略已经统一负责各工具的完整结果保留与读取。
- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费者必须执行调用者权限边界。
- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费方必须执行调用者权限边界。
- **把搜索、追踪与精确读取合并为一个带操作选择器的工具**:不予采纳,因为职责单一的名称能为模型提供更清晰的 schema、默认值、表现意图与后续选择。
- **只返回一层谱系**:不予采纳,因为 spill 已消除行内大小方面的理由,而单层输出会遗漏关系且没有继续读取路径。
## 验证
包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方不存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。
包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理完全停稳、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 完全停稳、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置与 TUI/Web 组合测试证明面向模型的消费方不存在于 TUI、Web 与无头界面,而组装后的 ACP 请求头快照证明自动化界面默认不包含它。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。
## 后果
模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。
模型无需获得存储权限或继续状态,即可通过提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。

View File

@@ -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/feature/2026-07-25-session-list-browsing-and-manual-order.md
2026-07-25-session-list-browsing-and-manual-order.md: bd04e7f74c8a4540d68e60ad68965e05de76bce9
2026-07-25-session-list-browsing-and-manual-order.zh.md: 8ec5f71943a68a70f46fbd4c7702e4556b1892ec
2026-07-25-session-list-browsing-and-manual-order.zh.md: 216ade7b723f82740d485a435d360a060a2b42d9

View File

@@ -6,55 +6,55 @@ Status: implemented
## Problem
[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。
[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组会话列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、会话行悬停要出详情卡与操作菜单、workspace 要能改名、组内会话要能手动排序。
两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。
两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃会话持久化地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker slot 放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。
## Decision
### 平铺行与浏览态
group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级 session 行;In one list 把所有 session 合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为 session 数据;完整 fork 行为由 [Web session fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序在各组内展示同级会话行;In one list 把所有会话合并后严格按 `updatedAt` 新→旧排序。两种模式都不把 `parentId` 投影成列表层级,fork 谱系只保留为会话数据;完整 fork 行为由 [Web 会话 fork 操作](2026-07-27-web-session-fork-actions.md)定义。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。
### 行交互
- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。
- session 行 … 菜单:Rename / Fork session / Delete session,其中 Rename 与 Fork 已接线,Delete 仍为纯视觉;workspace 组头 … 菜单的 Rename / Delete workspace 均已接线。菜单鼠标移出即关。
- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。
- 会话行悬停 500ms 出详情卡(完整标题、相对时间、状态行;状态本期只有 running/idle 两态,枚举扩展待协议格式(wire format)增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。
- 会话行 … 菜单:Rename / Fork session / Delete session,其中 Rename 与 Fork 已接线,Delete 仍为纯视觉;workspace 组头 … 菜单的 Rename / Delete workspace 均已接线。菜单鼠标移出即关。
- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。
### workspace.rename
`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。
### 手动排序:insertSessionBefore 取代活动置顶
### 手动排序:insertSessionBefore 取代活动置顶
`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。
`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新会话 attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:有锚点时插到锚点前,省略时 append 到末尾)。实体只对不在账的会话/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。
UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子与源会话一样独立排序)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。
UI 为组内会话行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子会话及其源会话各自独立排序)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚较新的帧。
### 壳/区域切分
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/添加工作区图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspacePickFlow`(区域内直接组件组合;在[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)之前名为 `WorkspaceCreateFlow`)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。
ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` slot;壳与区域的约定只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/添加工作区图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspacePickFlow`(区域内直接组件组合;在[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)之前名为 `WorkspaceCreateFlow`)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero slot);原 `sidebar.workspace` picker slot 与声明感知延迟注册随之删除。
## Alternatives considered
**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。
**保留活动置顶、拖拽仅作临时调整**——手动序在下一次会话活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。
**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。
**排序报文用数字下标**——`{ index }` 在拖拽窗口期会漂移:host 前插新会话(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。
**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。
**drop 后乐观重排**——client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。
**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。
**rename 对话框留在 ui-sidebar(最小改动)**——正是问题本身:workspace 域的对话框散落在借来的位置里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。
**WorkSpace 模式按 fork 谱系嵌套 session** —— 嵌套会让当前子会话依赖祖先展开态才能可见,也让组内手动序只能移动根节点;`parentId` 是 lineage 数据,不是列表导航结构。所有 session 拍平成同级行后,每行都可独立打开、搜索与排序;In one list 仍因没有 workspace 持久化载体而禁用拖拽。
**WorkSpace 模式按 fork 谱系嵌套会话**——嵌套会让当前子会话依赖祖先展开态才能可见,也让组内手动序只能移动根节点;`parentId` 是 lineage 数据,不是列表导航结构。所有会话拍平成同级行后,每行都可独立打开、搜索与排序;In one list 仍因没有 workspace 持久化载体而禁用拖拽。
## Consequences
- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。
- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。
- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。
- session Delete 的功能接线与状态枚举扩 wire,留待后续迭代。
- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的协议格式约定随之改为手动序措辞。
- 壳/区域两事实约定把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随会话列表功能演进。
- 平铺模式不支持重排,也没有在 workspace 中创建会话的入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。
- 会话 Delete 的功能接线与扩充协议格式中的状态枚举,留待后续迭代。
## Testing
包级用例覆盖派生(deriveGroups/deriveFlat)、同级 session 行、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用,并钉住 fork 后没有 session 展开控件。
包级用例覆盖派生(deriveGroups/deriveFlat)、同级会话行、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture(测试前置数据)桩;`apps/web` keyless 快照回归覆盖装配后的应用,并钉住 fork 后没有会话展开控件。

View File

@@ -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/feature/2026-07-25-workspace-ui-product-flow.md
2026-07-25-workspace-ui-product-flow.md: 7a6a41e19d2930fbcbf7ba5fc9e6809d96e23166
2026-07-25-workspace-ui-product-flow.zh.md: a40f374fd794b11bff0de72cbc822fd638cd267c
2026-07-25-workspace-ui-product-flow.zh.md: 14f33b0eebcf8885670e72cfb6e2c9a87b7a3432

View File

@@ -1,9 +1,9 @@
# Agent Note: Workspace UI 完整产品动线
[English](2026-07-25-workspace-ui-product-flow.md) | 中文
Status: implemented
[English](2026-07-25-workspace-ui-product-flow.md) | 中文
## Problem
[Domain KV storage 与 Workspace entity](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)定义了 Workspace 的持久实体、路径规范和有序 Session 账本,但没有定义 Host 接线、历史数据初始化或 GUI 动线。GUI 同时呈现 Workspace 和 Session;用户进入 New Session 后必须立即输入,即使此时还没有 Host Session,甚至没有 Host Workspace。
@@ -25,11 +25,11 @@ Host 在 Workspace entity 上提供以下 GUI 接线:
| `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach |
| `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session |
`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host stream 推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。
`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host 流推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。
Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并 fail loud。
Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并明确报错。
Workspace domain 以 durable marker 区分“从未初始化”和“已初始化但为空”。marker 未设置时,Registry 只调用 `SessionPersistence.list()` 读取 header 元数据,不调用 `load`、`inspect`、history 或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。
Workspace domain 以 durable marker 区分「从未初始化」和「已初始化但为空」。marker 未设置时,注册表只调用 `SessionPersistence.list()` 读取 header 元数据,既不调用 `load` 或 `inspect`,也不读取历史数据或解析事件正文;有效 cwd 按 canonical path 分组,组内 Session 与 Workspace 组均按 header `createdAt` 降序初始化。Bootstrap 可重入,最后才写 marker;marker 写入后,绕过 `workspaceId` 的新 Session 不再被自动收编。
### Client 对象模型
@@ -56,15 +56,15 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预
### 首次发送与恢复
前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、prompt send 顺序推进:
前端 Session 的 `pendingPrompt` 在 Host 接受消息前始终保留原文。首次发送按 Workspace materialize、Session attach、提示词发送顺序推进:
1. Workspace 创建失败时,Workspace Intent 保留输入与错误,Session 仍指向该对象。
2. Session 创建在发布前失败时,Session Intent 回到可编辑状态,以同一预分配 SessionId 重试。
3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留 prompt,后续重试 attach。
4. prompt 失败时,Session 保留 prompt 并只重试 send,不重复创建 Workspace 或 Session。
5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原 prompt 和可见错误。
3. `workspace-attach-failed` 证明 Session 已发布;同一 Session 对象进入真实列表并保留提示词,后续重试 attach。
4. 提示词发送失败时,Session 保留提示词并只重试发送,不重复创建 Workspace 或 Session。
5. Session 创建期间若页面切换到另一个 Intent,旧 Session 即使随后发布也不自动发送;它保留原提示词和可见错误。
RPC lost response、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。
RPC 响应丢失、Host frame 先于 completion 和 completion 先于 Host frame 都通过预分配 SessionId 与对象身份收敛。Manager 对 Host view 做有序 upsert,本地 materialize 时优先保留原对象身份,不生成同 id 的临时第二行。
### Sidebar 与排序
@@ -72,7 +72,7 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定
组内严格使用 `Workspace.sessionIds`。新 attach 的 Session 放在首位,后续某个 Session 活跃时 Host 只前移该 id 并持久化。Client 不在 Session list 到达后按时间整体重排,因此不会先显示一套 Workspace 顺序再因 hydration 瞬间跳动。
前端 Session Intent 只有在目标是真实 Workspace 时才作为 “New session” 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。
前端 Session Intent 只有在目标是真实 Workspace 时才作为 「New session」 行显示,并临时计入该组 Session 数量;目标是 Workspace Intent 时,Workspace 与 Session 都不进入 sidebar。Intent 发布后由同一预分配 id 对应的真实行接替,刷新后 Intent 行和临时计数一起消失。搜索模式不保存或筛选 Intent 行。
无法归入任何 Workspace 的真实 Session 进入 Ungrouped。Host `session-added` 与 `workspace-changed` 可以任意顺序到达,列表合并不依赖 frame 顺序。
@@ -80,19 +80,19 @@ Workspace 组严格使用 Host 返回的持久顺序。Bootstrap 一次性确定
### React 与 slot 边界
React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped hooks,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free runtime 对象层。
React 组件只消费 `useSessions`、`useWorkspaces` 与 session-scoped 钩子,不拥有实体生命周期。Zustand store 只保留布局、当前 view、普通真实 Session 的 composer 文本和其他纯呈现状态;Session/Workspace Intent、materialize phase、错误和 retained prompt 位于 React-free 运行时对象层。
Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host event 只由 Host mutation 与 stream adapter 产生。
Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSession`、`updateSessionPrompt`、`sendSession`、`open` 与 `toggleSidebar`。Workspace picker 复用同一组件与 `createWorkspace` seam;owner 只提供 popover 开关、锚点和选中回调。呈现层不直接发送 `host/workspace-changed`,Host 事件只由 Host mutation 与流适配器产生。
## Alternatives considered
**为待创建 Workspace 与 Session 保存独立页面记录。** 该方案在 materialize 后需要替换身份并转交输入、错误、焦点和 sidebar 行;对象自身的 Intent 状态可以保持身份连续。
**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager/Service 的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。
**由呈现层或 root Zustand store 编排对象生命周期。** 该方案会重复 Manager 与服务的职责,并把领域状态带回 React。标准化动作由 runtime service 提供,slot 只注入呈现所需的窄接口。
**零态立即创建 Host Session 或 Host persistence intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。
**零态立即创建 Host Session 或 Host 持久化 Intent。** 未输入页面会进入 Host 生命周期,并改变刷新语义;前端 Session 在首次发送前只保留 page-local Intent。
**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,“创建 Workspace”与“准备 Session”语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。
**显式 Create Workspace 延迟到首次发送。** 用户确认后 sidebar 仍看不到真实空 Workspace,「创建 Workspace」与「准备 Session」语义混合;只有系统自动产生的零 Workspace Intent 延迟 materialize。
**持续按 cwd 动态派生 Workspace。** 该方案无法表达空 Workspace、稳定显示名和显式顺序,也会自动收编非 Workspace 调用方;cwd 只用于一次历史 bootstrap 与成员双向校验。
@@ -104,7 +104,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe
- 完全无 Workspace 的零态不写 Host 且允许输入;显式 Create Workspace 立即创建并显示空 Workspace。
- 前端 Session 与 Workspace 在 materialize 前后保持对象身份,输入、错误、焦点和 sidebar 投影始终来自对象层。
- 首发按 Workspace、Session、prompt 顺序推进,各成功阶段不回滚,输入在 prompt 接受前不丢失,创建重试使用同一 SessionId。
- 首发按 Workspace、Session、提示词顺序推进,各成功阶段不回滚,输入在提示词被接受前不丢失,创建重试使用同一 SessionId。
- Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。
- 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。
- 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。
@@ -116,6 +116,6 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe
- SessionHeader 不记录最后活跃时间,历史 bootstrap 只能按 `createdAt` 初始化;此后由真实 Session 活跃事件逐项前移。
- 历史 cwd 缺失、目录无效或 realpath 失败的 Session 留在 Ungrouped;本期没有手动收编入口。
- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 契约。
- 页面刷新会丢弃未 materialize 的 Workspace/Session Intent 和尚未被 Host 接受的输入,这是 page-local 约定。
- 显式 Create Workspace 立即落盘,用户不发送就离开也会留下空 Workspace。
- Host Session 在首个事件前仍遵循现有懒持久化语义;前端 Intent 不改变 Host 重启后的空 Session 行为。

View File

@@ -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/feature/2026-07-26-todo-parallel-in-progress.md
2026-07-26-todo-parallel-in-progress.md: 8d047f33ab1aebd8c0de2a0a5e90e7efb1b28154
2026-07-26-todo-parallel-in-progress.zh.md: 26b7081e7b4e3688a46a2857da91ba45328532a9
2026-07-26-todo-parallel-in-progress.zh.md: 64047e8b27e779e6ca23ea6c435bcdfecf38066d

View File

@@ -12,7 +12,7 @@ Status: implemented
把单一 `in_progress` 上限从固定规则改为部署策略,并要求每个组合都作出选择:
- `packages/todo/tool-todo/src/index.ts` 新增必填的 `Config.allowParallelInProgress` 字段。为 `true` 时,`execute` 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 `false` 时,描述要求恰好一个,`execute` 拒绝标记更多的调用。
- `packages/todo/tool-todo/src/index.ts` 新增必填的 `Config.allowParallelInProgress` 字段。为 `true` 时,`execute` 接受任意数量的活跃条目,描述指示模型把每个正在处理的任务标记为 `in_progress`(并行工作时可以有多个,顺序工作时只有一个),并在仍有工作未完成时至少保留一个;为 `false` 时,描述要求恰好一个,`execute` 拒绝标记多个活跃条目的调用。
- `packages/todo/tool-todo/src/invariant.ts` 中的持久日志不变式不再拒绝含多个活跃条目的快照,且不跟随该配置,因此此前持久化的日志不受影响,并行快照在任一策略下都能干净回放。
其余编码的不变式保持不变:`content` 去除首尾空白后非空且唯一、status 为合法枚举值。本决定取代[原始设计的校验决策](2026-06-29-todo-write-tool.md)中「至多一个活跃」的条款;该 Agent Note 的其余部分(整列表替换、日志支撑的状态、单一所有者)依然成立。
@@ -23,7 +23,7 @@ Status: implemented
## 该策略是部署层的选择
并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent 是否会并发展开工作,在组装期就是可知的。因此该策略是必填的 `Config` 字段,而非常量或默认值:每个 cordis.yml 组合都会有意设置 `allowParallelInProgress`,为可能并行展开工作的 agent 选择 `true`,或为单活跃项纪律选择 `false`。
并发的活跃任务是否合理,取决于工具无法观测的运行时并发情况——但一个部署的 agent(智能体)是否会并发展开工作,在组装期就是可知的。因此该策略是必填的 `Config` 字段,而非常量或默认值:每个 cordis.yml 组合都会有意设置 `allowParallelInProgress`,为可能并行展开工作的 agent 选择 `true`,或为单活跃项纪律选择 `false`。
该开关会同时改变面向模型的指令与接受的输入。把两者拆开才是 bug:描述要求只保留一个活跃任务、而 `execute` 却接受多个,等于教给模型一条工具并不遵守的规则;反过来则会拒绝描述所邀请的调用。描述中只有活跃状态那一句会变化,因为这是该策略唯一改变的指令。
@@ -37,18 +37,18 @@ Status: implemented
## 展示面是本次改动的一部分
解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR 的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。
解除上限使一种此前任何渲染器都不曾收到的列表形状变得可达,因此本分支 stack(栈叠)在 [web todo 展示](2026-07-23-web-todo-display.md)之上,而不是与之并行落地:两者都改 `tool-todo`,而 GUI 正是并行计划变得可见的地方。web 有两处用 `todos.find(t => t.status === 'in_progress')` 推导单行摘要——折叠态的计划横条表头与 `todo_write` 工具行——在旧上限下这个 `find` 是完备的,因为最多只能有一个条目匹配。一旦有多个活跃项,它会静默丢掉除第一个之外的全部活跃条目:一个四条目、三个任务在跑的计划折叠后只显示其中一个的名字,工具行读作 `1/4 已完成 · <一个任务>`,而另外两个仍在进行。展开态的列表始终正确(它遍历每个条目),这也是两个 PR(Pull Request)的测试都没抓到它的原因——只有折叠表头与工具行丢失了信息。面板重做把折叠表头的具名提示换成以 `·` 连接的各状态计数(本地化后形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略),它能正确报告并行工作,且不需要任何可被截断的名字;工具行才是本分支仍需修的那一处。
工具行改用 `toolviews/plan-summary.ts` 中的 `planSummary`。它给出第一个活跃条目,并计数其余活跃项,因此工具行报告的是有多少任务在跑,而不是暗示只有一个。列出全部活跃条目被否决了:工具行是单行,无上界的拼接会溢出——在列表做不到的地方,计数能够可预测地降级。该推导放在 toolviews 域内而非 `contract/`(域间共享面):面板自行内联计算其计数,与工具行不共享任何东西,因此放进 contract 会声明一种已不存在的共享关系。
`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 `overflow: hidden` / `text-overflow: ellipsis` 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数交给共享的 `ToolRow`,作为 `summarySuffix`——一个紧邻被省略号截断的摘要文本、且不会收缩的槽位;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。
`planSummary` 把任务名与计数作为两个独立字段返回,而不是一个拼好的字符串,因为工具行用 `overflow: hidden` / `text-overflow: ellipsis` 截断其摘要文本。计数接在任务名之后时位于可截断文本的末端,于是恰恰是让计数变得有意义的那些场景——窄视口、长任务名——会把它裁掉,让并行计划看起来与顺序计划无异。因此工具行把计数交给共享的 `ToolRow`,作为 `summarySuffix`——一个紧邻被省略号截断的摘要文本、且不会收缩的slot;一个预先拼好的字符串无法表达这个切分,而把计数放到任务名之前也被否决了:读者首先要找的是任务名。
`summarySuffix` 是 `ToolRow` 上的槽位,而不是 todo 工具行自有的标记:每个 toolview 都经由这个共享组件渲染,而它的 `summary` 是一个会被省略号截断的普通字符串,容不下一个必须挺过截断的片段。该后缀落在 `.summary` 规则之外,因此重复了该规则的 `font-size` 与 `line-height`——Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。错误行会丢弃该后缀,因为它折叠态的摘要是失败行,而非任何由调用 args 推导出的内容。
`summarySuffix` 是 `ToolRow` 上的slot,而不是 todo 工具行自有的标记:每个 toolview 都经由这个共享组件渲染,而它的 `summary` 是一个会被省略号截断的普通字符串,容不下一个必须挺过截断的片段。该后缀落在 `.summary` 规则之外,因此重复了该规则的 `font-size` 与 `line-height`——Web 外壳把正文字号留在浏览器默认值而非该行的 14px,所以未加样式的 span 会明显大于同一 24px 行内与之并列的文本。错误行会丢弃该后缀,因为它折叠态的摘要是失败行,而非任何由调用 args 推导出的内容。
## 暂缓项
两条 review 结论在此记录而非在本分支修复。`summarySuffix` 这个 span 没有无障碍名称,屏幕阅读器读出的数量缺少它所修饰的名词(`… 实现 fixture 样本 +1`);为它命名会引入带自身测试契约的本地化文案,这属于对整条 `ToolRow` 摘要行做的无障碍专项,而不属于某一行。以及,当*第一个*活跃条目的 content 不可用时——缺失、类型不对、或 trim 后为空——行会连同数量一起丢掉活跃子句,于是并行计划渲染成裸计数;向后跳到第一个可用活跃条目的方案被否决,因为调用 args 是一处明确未经校验的边界,模型给出的顺序是该行唯一能遵循的顺序,而只丢掉不可用的那个子句可以保住 `done`/`total` 计数——这两个数无论如何都是可信的。
两条 review 结论在此记录而非在本分支修复。`summarySuffix` 这个 span 没有无障碍名称,屏幕阅读器读出的数量缺少它所修饰的名词(`… 实现 fixture 样本 +1`);为它命名会引入带自身测试约定的本地化文案,这属于对整条 `ToolRow` 摘要行做的无障碍专项,而不属于某一行。以及,当*第一个*活跃条目的 content 不可用时——缺失、类型不对、或 trim 后为空——行会连同数量一起丢掉活跃子句,于是并行计划渲染成裸计数;向后跳到第一个可用活跃条目的方案被否决,因为调用 args 是一处明确未经校验的边界,模型给出的顺序是该行唯一能遵循的顺序,而只丢掉不可用的那个子句可以保住 `done`/`total` 计数——这两个数无论如何都是可信的。
## 后果
现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照 sidecar。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Note 先前记过的两次点时刻计数都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些 sidecar —— 包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖 —— `pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture 的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP `todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。
现在 todo 列表可以忠实反映并行执行,并且每个展示面都能一次渲染多个活跃标记:计划横条的表头会计数活跃条目,工具行则需要上述推导。设置 `allowParallelInProgress: true` 的组合不再拒绝一种此前无效的快照形状;设置为 `false` 的组合仍保留旧的拒绝行为,而持久日志不变式两者都接受。面向模型的描述发生了变化,这重新记录了 tool-catalog 页面以及每个带有 todo schema 的快照伴随文件。此处不记录数量:该集合会随每个新落地的 pin 场景增长,而本 Agent Note 先前记过的两次时点统计都在几天内失实。有效规则是:改动工具描述的分支必须刷新它分叉之后落地的那些伴随文件——包括固定 subagent 类工具的编号文件 `tool-schemas.<n>.expected.json`,其 schema 不被父场景覆盖——`pnpm run test:snapshot:refresh` 可以无 key 地对整个语料完成刷新。web fixture(测试前置数据)的 todo 样本现在有两个条目处于 `in_progress`,因此两个由 fixture 驱动的展示面渲染的都是并行计划。`packages/client/ui-conversation/tests/todo-panel.spec.tsx` 在 src 上固定工具行摘要与计划横条,ACP(Agent Client Protocol)`todo-write` 场景录制的是三条目、两个活跃的计划,而 `apps/web/tests/todo-row.snapshot.ts` 在组装后的应用中固定这两个面——它从构建产物 `packages/client/*/lib/client.js` 启动,因此是唯一覆盖 keyed 注册与打包接线的地方。该文件把 `summary`、`suffix` 与横条表头记录为独立字段,因此即便拼接后的文本读起来一样,把 `+N` 计数折回摘要字符串也会改变预期输出。

View File

@@ -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/feature/2026-07-27-native-workspace-directory-picker.md
2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2
2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8
2026-07-27-native-workspace-directory-picker.zh.md: e45d45a95b27b19d573d6bd709357d08a2e2f3fc

View File

@@ -14,7 +14,7 @@ Status: implemented
工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。
## 交互契约
## 交互约定
- 在 macOS、Windows 和 Linux 上,选择器一次只允许选择一个目录。
- 取消系统对话框不会显示提示,并返回 `null`。
@@ -27,7 +27,7 @@ Status: implemented
只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。
平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话:
平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上进行进程内 COM 交互:
- macOS:`osascript` 和系统文件夹选择器。
- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。

View File

@@ -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/feature/2026-07-27-skill-catalog-hot-refresh.md
2026-07-27-skill-catalog-hot-refresh.md: 43c2da75aeb0031dffb5f47d7e76805baa2920e5
2026-07-27-skill-catalog-hot-refresh.zh.md: 04d39dd767e5438c57d3eb29523a8ff26592465c
2026-07-27-skill-catalog-hot-refresh.zh.md: 60b08cd7b82dfe4edc5f8136d756df2c9097b3fe

View File

@@ -12,13 +12,13 @@ skill(技能)摘要是模型的路由输入,但本地 skill 可在会话
## 决策
skill 服务将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或释放时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会重试一次;如果这次重试也被后续修订取代,则最新候选项会作为不完整且不缓存的观测返回。资源释放或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。
skill 能力将目录成员关系与指令正文加载分离。`ctx.skills.snapshot()` 返回摘要及一个完整性位。`ctx.skills.registerProvider(factory)` 会向同步工厂提供一项注册作用域内的 `{ signal, invalidate }` 控制能力:`invalidate()` 只会将该精确活动注册标记为脏,并丢弃已完成目录缓存;注册失败或 dispose(资源释放)时,信号会中止。提供方返回的数组是完整发现的简写形式;显式的不完整观测可以保留可读候选项供直接加载,但不能缓存,也不能作为面向模型消费方的权威结果。在发现期间,如果提供方或运行时 generation 发生变化,系统会重试一次;如果这次重试也被后续修订取代,则最新候选项会作为不完整且不缓存的观测返回。dispose 或替换后的延迟失效操作不会执行任何操作,因为该能力已被撤销。
`@deepseek-ai/dsh-skill-local` 直接依赖 Chokidar,并观察与目录相关的宿主路径。已有根目录会监视其直属 skill bundle 目录、平铺的 Markdown 条目和直属 `SKILL.md` 条目文件。新增、移除和目录变更会使成员关系失效;文件变更还支持刷新 frontmatter 中的 `name` 和 `description`。bundle 内更深层的资源文件会被忽略。同一微任务批次中的事件会合并为一次失效。项目 watcher 使用有界集合,并按最久未观察顺序淘汰。
系统从缺失根目录最近的现有祖先开始,使用 `fs.watchFile` 每次跟进一层缺失路径片段;真实根目录出现后,再交给 Chokidar。每次发现操作都会在扫描前重新探测所保留的根目录/祖先模式。即使子项移除在根目录 `unlinkDir` 事件之前就触发失效并发布权威空目录,或者该事件根本没有到达,这项独立探测也会在删除后重新建立祖先观察。Chokidar 配置公开原生事件或轮询模式、写入稳定性、轮询间隔、符号链接跟随选项和项目 watcher 容量。第一方 `write` 和 `edit` 工具观察会同步使相关提供方失效,因此下一个模型步骤无需等待宿主事件投递,就能看到自身改动。watcher 启动或运行失败会被记录并触发重试;发现过程仍会返回可读候选项供直接加载,但会报告不完整观测。资源销毁会关闭 watcher,并忽略延迟回调。
`@deepseek-ai/dsh-tool-skill` 在首次观察到非空完整目录且返回 enter 的 `agent/pre-step` 中,贡献一条带来源的 `UserMessage`。每次 pre-step,它都会应用 `skill` 工具的精确可见性,对 `<available_skills>` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件会向 enter 决策贡献完整替换目录;所有 skill 消失时也包含显式空目录,reject 或监听器失败则不记录任何内容。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次完整且进入步骤的观察会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。
`@deepseek-ai/dsh-tool-skill` 在首次观察到非空完整目录且返回 enter 的 `agent/pre-step` 中,贡献一条带来源的 `UserMessage`。每次 pre-step,它都会应用 `skill` 工具的精确可见性,对 `<available_skills>` 标签之间精确渲染的文本计算哈希,并从后向前扫描只读会话事件且不复制,以查找该插件发布的最新一条可识别且仍可见的目录。digest 变化时,插件会向 enter 决策贡献完整替换目录;所有 skill 消失时也包含显式空目录,reject 或监听器失败则不记录任何内容。如果没有目录仍然可见,但历史事件中存在可识别目录,则说明压缩(compaction)已将其遮蔽,下一次进入步骤时的完整观测会重新建立当前目录,包括空 tombstone。如果当前目录为空且历史上从未发布目录,则不发送任何内容;不完整快照则保留最后一次完整的模型视图。反向扫描通常在最新且仍可见的目录处停止;当压缩遮蔽所有目录时,它会以一次 O(session-events) 扫描的成本确认这一事实。
TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills/change` 不携带 diff;TUI 会为活动会话的 cwd 重新获取 `snapshot()`,仅应用最新的完整结果,并在观测不完整时保留先前命令。完整的空结果会清除陈旧补全项。pi-tui 在其提供方被替换时会关闭自动补全,因此如果目录在用户输入斜杠命令名称期间到达,还会触发一次仅用于更新建议的当前草稿重查。
@@ -33,10 +33,10 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills
- **将实时目录放入 World State**:不予采纳,因为目录替换是模型可见的会话输入,必须能够从事件日志重建。持久注入历史已经提供回放、恢复、fork 和压缩语义,无需再引入一套可变状态层。
- **只依赖 `fs/observed`**:不予采纳,因为 IDE、Git、shell 命令和外部进程都不会经过该 seam。该事件仍作为第一方工具的低延迟快速路径,宿主监视则补齐覆盖。
- **为每个 `SKILL.md` 正文计算哈希或版本**:不予采纳,因为模型最初只看到名称和描述,提供方已经在每次工具调用时重新读取正文。正文修订会产生目录流量,却不会改变路由,也不足以成为改写历史工具结果的理由。
- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和产物都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。
- **监视每个 bundle 资源**:不予采纳,因为参考资料、脚本和资源都是按需加载的,不影响类别列表。宽泛的递归监视会增加失效、描述符压力和平台差异,却不能改善路由。
- **将部分发现或失败发现发布为新目录**:不予采纳,因为暂时读取失败不能证明文件已删除。完整性位让面向模型的消费方保留最后一次完整目录,直到完整观察成功。
- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在释放后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。
- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务契约。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。
- **保留公开的 `invalidateProvider(provider)`**:不予采纳,因为这会公开一项注册表变更方法,并要求调用方重复提供注册表已经持有的身份。发给工厂的闭包会将失效绑定到单个注册,并在 dispose 后失去作用,因此观察方既不需要访问注册表,也不需要提供方身份。
- **现在提取通用 Cordis 文件监视服务**:暂缓,直到另一个消费方确立可复用的服务约定。本地提供方标出了其 Chokidar 和缺失根目录观测边界,以便后续提取;skill 路径过滤以及对提供方失效闭包的调用仍属于 skill 专用逻辑。
## 影响
@@ -44,5 +44,5 @@ TUI 将同一失效通知作为界面状态而非会话历史来消费。`skills
- TUI 的 `/skill:` 补全会收敛到同一份完整目录,而不会让每次按键都阻塞于发现;打开的斜杠命令名称草稿会在目录到达时刷新。
- 目录消息采用仅追加、日志记录和全量列表快照。它们会保留较早的可重用 token;替换目录会显式停用陈旧名称,每次 digest 实际变化时,token 成本与当前目录大小成正比。
- 仅修改正文不会产生目录消息。后续工具调用会看到当前内容,而先前工具结果仍准确记录模型之前加载的内容。
- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁契约会限制这些成本。
- 缺失根目录轮询和 Chokidar 引入一个有人维护的运行时依赖、宿主 watcher 资源、有界检测延迟和部署可调参数。有界项目集合与资源销毁约定会限制这些成本。
- 远程或未来的可变提供方会保留各自注册作用域内的失效闭包,并通过自身观察机制调用它;注册表不会强制采用通用 watcher 或 TTL。

View File

@@ -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/feature/2026-07-27-trajectory-inspection-ledger.md
2026-07-27-trajectory-inspection-ledger.md: e3fc22234c1df449c99eac90af27de7da0b6f202
2026-07-27-trajectory-inspection-ledger.zh.md: abf8fd3bcaeabba6061bd415acbe8ddefef7ac79
2026-07-27-trajectory-inspection-ledger.zh.md: 736523ebb02b8ea3c6e4cdbf9bc27f67779c7426

View File

@@ -1,4 +1,4 @@
# Agent Note:轨迹检查记录表
# Agent Note: 轨迹检查记录表
Status: implemented
@@ -16,13 +16,13 @@ Status: implemented
- 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。
- 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。
- 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。
- 客户端 runtime 提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费者承担这些结构。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以 purpose 区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。
- 客户端运行时提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费方承担这些结构。
- 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。
- 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。
- 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。
- 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤数和工具调用数;折叠助手时对其工具调用后代应用相同交互。全局控件可同时折叠或展开这两个层级。
- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。
- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。
@@ -45,7 +45,7 @@ Status: implemented
**复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。
**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包(package)选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。
**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。
**将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。
@@ -53,4 +53,4 @@ Status: implemented
## 后果
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表契约锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。
轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。

View File

@@ -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/feature/2026-07-27-web-session-fork-actions.md
2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf
2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848
2026-07-27-web-session-fork-actions.zh.md: 30c6fbbe7930ab17ca707d8a8fa0d4fc288d150e

View File

@@ -1,4 +1,4 @@
# Agent Note: Web session fork 操作
# Agent Note: Web 会话 fork 操作
Status: implemented
@@ -6,30 +6,30 @@ Status: implemented
## Problem
Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 Web 端没有一份统一的交互契约。Session 行菜单只能表达「从最新完成轮分支」,消息 IconActions 还需要表达「从这条消息所在轮分支」;如果两处各自解释边界、切换与失败行为,同一个用户动作会形成两套语义。把 fork 子会话嵌套在源会话下还会让新选中的子会话依赖祖先展开态才能看见,并削弱 workspace 的手动排序模型。
会话存储已提供按已完成轮次前缀创建子会话的 fork 原语,但 Web 端没有一份统一的交互约定。会话行菜单只能表达「从最新已完成轮次分支」,消息 IconActions 还需要表达「从这条消息所在轮次分支」;如果两处各自解释边界、切换与失败行为,同一个用户动作会形成两套语义。把 fork 子会话嵌套在源会话下还会让新选中的子会话依赖祖先展开态才能看见,并削弱 workspace 的手动排序模型。
## Decision
本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。
Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。
Web 的会话行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。会话行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。
`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。
`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触会话服务,消息组件只回传事件 `seq`。会话行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。
Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage 和后续查询,但不控制 session 列表可见性。
会话 lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行,每行都可独立打开、搜索和拖拽;In one list 模式继续按 `updatedAt` 严格排序;Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage 和后续查询,但不控制 session 列表可见性。
## Alternatives considered
**只接 session 行菜单。** 否决:用户在消息处已经选择了更精确的上下文,强迫其回到列表只能退化为最新完成轮,且已展示的消息分支图标会成为无响应控件。
**只接会话行菜单。** 否决:用户在消息处已经选择了更精确的上下文,强迫其回到列表只能退化为最新已完成轮次,且已展示的消息分支图标会成为无响应控件。
**只允许用户消息分支。** 否决:已定稿 assistant 内容同样有稳定事件 `seq`,host 会把它归入所属完成轮;让两个外观相同的分支按钮只有一个可用会制造不可见的行为差异。
**只允许用户消息分支。** 否决:已定稿 assistant 内容同样有稳定事件 `seq`,host 会把它归入所属已完成轮次;让两个外观相同的分支按钮只有一个可用会制造不可见的行为差异。
**按 `parentId` 把 fork 子会话嵌套在源会话下。** 否决:lineage 不是导航所有权;嵌套要求自动展开祖先才能看见当前项,并让子会话无法参与 workspace 的同级手动排序。
**由消息组件直接调用 session 服务。** 否决:client 组件不得接触 `ctx` 或业务服务;注入回调让 mutation 留在 apply 世界,组件保持纯 props。
**由消息组件直接调用会话服务。** 否决:client 组件不得接触 `ctx` 或业务服务;注入回调让 mutation 留在 apply 世界,组件完全由 props 驱动。
## Consequences
用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。
用户可从会话行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新已完成轮次」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。
Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。
Fork 与子会话改名失败都保持静默并保留源会话的选中状态,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。

View File

@@ -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/feature/2026-07-27-web-session-search.md
2026-07-27-web-session-search.md: 4c49c0e61ca1b15181e767e4f65353312f8a3e3f
2026-07-27-web-session-search.zh.md: e9aa00656443e544bad623540d07ff785b1285ef
2026-07-27-web-session-search.zh.md: 7f4cc9caf9738700ed5f24b3d0593f08d06117ac

View File

@@ -10,17 +10,17 @@ Web 侧边栏会展示会话标题及其 Workspace 归属,但无法根据只
## 决策
Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者契约,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。
Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态,而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告,但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引,因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者约定,又不会留下进程级派生文件。数据库从空状态启动,并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。
宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。
[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionsService.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。
结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionsService.searchResultLimit` 则把该常量重新公开给呈现插件。功能要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。
内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会递增,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。
## 故障与可见性契约
## 故障与可见性约定
搜索绝不会扩大会话可见范围:没有可供服务的 cwd 的冷会话会被排除,原因与它们不出现在 `session.list` 中相同;只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。
@@ -29,8 +29,8 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
## 曾考虑的替代方案
- **在浏览器中扫描每个会话的历史记录**:不予采纳,因为这会让 UI 承担传输与折叠开销;除非加载冷态日志,否则还会漏掉这些日志;并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。
- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端契约,将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 契约。会话级导航本身已有价值,无需让搜索与这项工作耦合。
- **首版即加入 trigram 或模糊搜索**:不予采纳,因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端约定,将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**:本版不予采纳,因为对话虚拟化与稳定的事件导航需要单独的 UI 约定。会话级导航本身已有价值,无需让搜索与这项工作耦合。
- **在侧边栏公开游标分页**:不予采纳,改为固定显示前 20 条结果并提示缩小查询范围;这样可使交互与取消状态保持有界。
## 后果
@@ -41,4 +41,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
## 测试
宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为契约。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为契约;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为契约。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为契约。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的 dispose(资源释放)固定为约定;语义提取测试与 SQLite/fixture(测试前置数据)搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose。fixture、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。

View File

@@ -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/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: 46c4949c56ebffe78e5ffda126a5784a1c648421
2026-07-27-web-subagent-conversations.zh.md: 6b2d7bbec1133cdeb2d0cd31a6f9a43b068d21f5
2026-07-27-web-subagent-conversations.zh.md: 4b5ff938550b6c452680e36331372db65bd6f3d7

View File

@@ -8,7 +8,7 @@ Status: implemented
由会话支撑的 subagent 具有持久化身份、持久化 transcript(文本记录)与直接 child 目录,但普通会话谱系无法将它们与 fork 区分开,也无法证明其描述符 mode 与继续执行授权。否则,绑定到 agent(智能体)的通用 Host 操作可能在其直接 parent 继续执行 owner 之外恢复或驱动 child。
浏览器必须遵守[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
浏览器必须遵守[可继续 subagent 约定](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
UI 还必须保留[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的成员、mode 与 diagnostic。共享服务报告采用实时优先规则的语料活动状态,而 Web 投影会将其替换为确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
@@ -20,7 +20,7 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage
通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md),该契约会暂停而非丢弃待处理工作。one-shot child 在 Web 端仍不可取消。
停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.md),该约定会暂停而非丢弃待处理工作。one-shot child 在 Web 端仍不可取消。
本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。
@@ -28,14 +28,14 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage
Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
| 设计意图 | 已交付契约 |
| 设计意图 | 已交付约定 |
| --- | --- |
| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何能力。 |
## 产品契约
## 产品约定
只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
@@ -45,11 +45,11 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 和 Send 也会准入另一个 FIFO 轮次,而独立的 Stop 经由 `subagent.interrupt` 路由([中断契约](2026-08-06-continuable-subagent-interrupt.md))。提示词失败会通过普通错误行为保留草稿。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 和 Send 也会准入另一个 FIFO 轮次,而独立的 Stop 经由 `subagent.interrupt` 路由([中断约定](2026-08-06-continuable-subagent-interrupt.md))。提示词失败会通过普通错误行为保留草稿。
已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。
## 宿主适配器与协议契约
## 宿主适配器与协议约定
`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域:
@@ -63,7 +63,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。
适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包(package)导入契约,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
## 客户端对象层与呈现
@@ -83,19 +83,19 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
**对已寻址 child 使用普通会话 API。** 不予采纳,因为通用历史不携带目录 mode 校验,而绑定到 Agent 的通用控件会有意拒绝 subagent,不会授予直接 parent 继续执行授权。
**将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端功能;webserver 只承载已校验的消息。
**将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端能力;webserver 只承载已校验的消息。
**新建 UI 包。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录与已寻址 child 呈现的统一 owner。
**自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,且在本决定当时,继续执行 seam 未公开具备安全授权的取消句柄。后来的[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md)以专用 subagent 路由补上了这项显式授权;回退到 `session.cancel` 仍被拒绝。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,且在本决定当时,继续执行 seam 未公开具备安全授权的取消句柄。后来的[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.md)以专用 subagent 路由补上了这项显式授权;回退到 `session.cancel` 仍被拒绝。
**只显示可继续 child。** 不予采纳,因为持久化目录有意描述由会话支撑的两种 mode。one-shot transcript 即使绝不接受后续消息,仍然有用。
**根据谱系推断 mode 或侧边栏过滤。** 不予采纳,因为普通 fork 共享 `parentSession`。由描述符支撑的目录负责提供 mode;单独的 `origin` 标记只是低成本的导航分类器。
**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。只读 header 的一层可展开性预查会在不读取后代事件的情况下保证点击前的稳定性,而展开仍是懒加载式权威直接 child 读取;现有 Host 帧会更新活动状态、恢复 parent 行的可展开性,并触发有界的成员刷新。
**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。仅基于 header 的一层可展开性预查会在不读取后代事件的情况下保证点击前的稳定性,而展开仍是懒加载式权威直接 child 读取;现有 Host 帧会更新活动状态、恢复 parent 行的可展开性,并触发有界的成员刷新。
**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为独立生命周期与用户所有权需要 side session 语义。
@@ -111,7 +111,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
## 后果
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose(资源释放)、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
- 除对正在运行的可继续 child 的当前轮次 Stop([中断契约](2026-08-06-continuable-subagent-interrupt.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
- 除对正在运行的可继续 child 的当前轮次 Stop([中断约定](2026-08-06-continuable-subagent-interrupt.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。

View File

@@ -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/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: 56d9abb2b09577b2fba14b9a655a941417e0c493
2026-07-28-continuable-subagent-conversations.zh.md: 77f497ceeef35dcc519242dca30c81759cc3b907
2026-07-28-continuable-subagent-conversations.zh.md: 40b6cc3e4d1685192cbd29131ac6987df51a2aeb

View File

@@ -1,4 +1,4 @@
# Agent Note(agent 决策记录):可继续的 subagent
# Agent Note: 可继续的 subagent
Status: implemented
@@ -54,7 +54,7 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
空闲的历史会话没有 `AgentHandle`。第一条通过鉴权的 `next-turn` 投递会根据持久化会话恢复激活,并将消息提交到其 inbox。冷恢复使用经过身份认证的确切在线 parent Agent 执行鉴权;当该 parent 有激活时,还使用它建立所有权,但绝不使用 parent 执行重建。
激活作为消费方会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权契约。激活 dispose 后,历史会话不消耗运行时内存。
激活会直接持有已发布的 `AgentHandle` 直至结算,而管理器的私有 activation-owner 作用域则是其 Cordis 结构化所有者。可继续 subagent 路径不创建任何中间的带结果执行包装层,包括 `SubagentRun`;一次性委派保持不变,且不属于该生命周期。远程提供方不在此处的范围内,引入时需要单独的激活所有权约定。激活 dispose 后,历史会话不消耗运行时内存。
### 激活生命周期
@@ -77,7 +77,7 @@ settled
no Activation
```
`running` 表示 Agent 正在执行准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
`running` 表示 Agent 有正在进行的准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
管理器根据 Agent 是否完全停稳以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时投递的 `next-turn` 会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。
@@ -99,7 +99,7 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
### child 所有权
每次激活都持有自身的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`。由于一个会话至多有一次在线激活,child 会话 id 足以标识在线 child,无需另一个运行时 incarnation 引用。`SessionHeader.parentSession` 记录持久化的直接 parent 身份,`ownedChildren` 中的成员关系则记录进程内所有权关系。
每次激活都持有自身的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`。由于一个会话至多有一个在线激活,child 会话 id 足以标识在线 child,无需另一个运行时 incarnation 引用。`SessionHeader.parentSession` 记录持久化的直接 parent 身份,`ownedChildren` 中的成员关系则记录进程内所有权关系。
当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
@@ -109,11 +109,11 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。
activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会以弱引用方式记录其属于这组祖先,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会保留其在这组祖先中的弱成员关系,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
### 报告投递扩展
可选的 child 作用域 `report(output)` 工具不会改变 Activation 驻留状态,也不会增加另一条队列。它每轮可调用零次或多次,不允许指定接收方,而是推导在线的直接 parent;投递采用静默注入还是唤醒 parent follow-up,由部署配置选择。[report 工具 Agent Note](2026-07-30-continuable-subagent-report-tool.md)规定其权限、确认、设置贡献和投递契约。
后来添加的可选 child 作用域 `report(output)` 工具不会改变 Activation 驻留状态,也不会增加另一条队列。它每轮可调用零次或多次,不允许指定接收方,而是推导在线的直接 parent;投递采用静默注入还是唤醒 parent follow-up,由部署配置选择。[report 工具 Agent Note](2026-07-30-continuable-subagent-report-tool.md)规定其权限、确认、设置贡献和投递约定。
### 延后的 steering(中途引导)
@@ -143,7 +143,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
### 范围
本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。
本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳约定,才能支持同样的行为。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
@@ -169,7 +169,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
**在没有 host 消费方的情况下暴露 host-user follow-up。** 公开的权限铸造方法和用户分支可以在没有历史 parent 的情况下实现冷恢复,但没有生产 host 适配器调用该操作。在具体的经认证宿主交互能够收到私有能力之前,该 seam 只接受确切的在线 parent。
**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 契约承载。
**返回 subagent 专属的投递路由。** `started`、`queued` 和 `resumed` 等标签重复了激活与 inbox 状态,却没有给调用方提供独立结果。复用 `MessageId` 和现有 inbox 事件,可以让投递关联继续由其所属的 Agent 约定承载。
**使用 child 引用计数。** 计数无法识别哪个 child 仍持有拆卸工作,也允许重复递减错误。身份集合会显式保留取消和 dispose 义务。

View File

@@ -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/feature/2026-07-28-cross-workspace-resume.md
2026-07-28-cross-workspace-resume.md: e8a8636911015a5b300b45a30e268ee1be5d47d1
2026-07-28-cross-workspace-resume.zh.md: 9e5e126f4b204927d7fb134a196dd0d0a98468d4
2026-07-28-cross-workspace-resume.zh.md: c56641be35bb37fb8aafb7613bdf8b8158e198d9

View File

@@ -16,15 +16,15 @@ Status: implemented
## Decision
共享 CLI 配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
共享 CLI(命令行界面)配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。
**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项调用由 app-boot 提供的 `dshHomePath('sessions')`,该函数使用规范的 `DSH_HOME` 解析器及其标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需针对会话的启动器补丁或槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。
**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项调用由 app-boot 提供的 `dshHomePath('sessions')`,该函数使用规范的 `DSH_HOME` 解析器及其标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需针对会话的启动器补丁或 slot。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。
**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。
因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。
**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。恢复始终使用默认的 `dsh --resume` 界面,因为 `meta` 会拒绝父级选项;交接过程已经进入持久化保存的目标目录。
**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了变化的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝发生变化的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在 dispose(资源释放)应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。恢复始终使用默认的 `dsh --resume` 接口,因为 `meta` 会拒绝父级选项;交接过程已经进入持久化保存的目标目录。
## Alternatives considered
@@ -36,7 +36,7 @@ Status: implemented
**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。
**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。
**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在 seam 处保持可见。
## Consequences
@@ -46,4 +46,4 @@ Status: implemented
## Testing
TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。构建后的 CLI PTY 测试会运行共享配置默认值与每进程派生的查询索引。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。
TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝发生变化的 cwd」的用例现在断言交接携带新目录。构建后的 CLI PTY 测试会检验共享配置默认值与每进程派生的查询索引。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。

View File

@@ -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/feature/2026-07-28-feedback-command.md
2026-07-28-feedback-command.md: 78cc9e89a5811b3f1520bae2bd971cbcf2522ede
2026-07-28-feedback-command.zh.md: c8870ca2988501d7899ae826e1bbcbad0bef5678
2026-07-28-feedback-command.zh.md: b0bba25ec3331123cb86066fc6c89186f2297cc9

View File

@@ -14,11 +14,11 @@ Status: implemented
位于 `packages/feedback/command-feedback/` 的 `@deepseek-ai/dsh-command-feedback` 通过 `ctx.commands` 注册一个全局 `feedback` 命令。`/feedback <text>` 给出确认;空输入或仅含空白的输入返回直接用法错误。处理器是同步的,只注入 `commands`,且没有任何配置。
本包(package)声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。
本包声明仅写入日志的 `feedback/record { text }` 会话事件,并导出 `recordFeedback(session, text)`,作为不依赖命令的生产方。该生产方丢弃前后空白,拒绝空结果,并且恰好追加一个事件。`/feedback` 委托给它,因此其他 UI、钩子或 host 集成无需构造斜杠命令也能记录同一个领域事实。
`dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。
采集对正在运行的 agent 与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为本地警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。
采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。
### 为何反馈拥有自己的事件
@@ -34,7 +34,7 @@ Status: implemented
### 一个新的分组
`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组只包含一个生产方包;跨领域的消费方留在各自所属的分组,而不是迫使这个包不断膨胀。
`packages/feedback/` 是新分组,因为现有分组都不拥有此职责:`goal/` 负责目标状态,`session-title/` 负责标题,`core/` 是产品主干。该分组只包含一个生产方包;跨领域的消费方留在各自所属的分组,而不是迫使这个分组不断膨胀。
## 考虑过的替代方案

View File

@@ -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/feature/2026-07-28-skill-invocation-policy.md
2026-07-28-skill-invocation-policy.md: 9d34bcb77ab7933ef9bf4e9b5227dade39903667
2026-07-28-skill-invocation-policy.zh.md: 4b190dbd095306d41837bd9734049f5e3d85da72
2026-07-28-skill-invocation-policy.zh.md: f196bcb66b05d2c7ba4b82a4e1124e335ef8aeb1

View File

@@ -8,17 +8,17 @@ Status: implemented
skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会移除禁止模型调用的 skill,而 `ctx.skills.get()` 仍是不过滤内容的可信 loader。该设计足以支持由模型发起的加载,却无法表示与 Claude 兼容的四类 skill:仅向用户公开、仅向模型公开、同时向两者公开,或者两者均不公开。TUI 从面向模型过滤后的列表中生成用户自动补全,并允许通过 `get()` 加载任意精确名称,这进一步放大了两类调用策略不匹配的问题。
本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation` 和正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型契约。
本地解析器还将一种内部驼峰式拼写暴露为 frontmatter。若要支持既有的负向字段 `disable-model-invocation` 和正向字段 `user-invocable`,需要建立持久且对称的领域表示,同时避免把所有可能出现的 YAML 键都变成跨包的无类型约定。
## 决策
`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行契约后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
`SkillSummary` 包含一个必填且类型明确的 `invocation: SkillInvocationPolicy` 对象,其 `modelInvocable: boolean` 和 `userInvocable: boolean` 字段为正向且对称。只有显式输入 seam 可以省略它:未提供策略的运行时 `SkillRegistration`,以及两个调用键均未提供的本地 frontmatter,都会在生成候选项或定义前解析为 `{ modelInvocable: true, userInvocable: true }`。未来的 frontmatter 键只有在具备消费方和执行约定后,才会进入领域模型;本地提供方仍将 frontmatter 解析为开放的 `Record<string, unknown>`,然后只把已识别字段及其默认值投影到规范化的类型化策略中。
`ctx.skills.list()` 返回所有胜出的摘要,不再替任何调用接口选择策略。`isModelInvocable(skill)` 和 `isUserInvocable(skill)` 分别直接读取对应的正向字段。`ctx.skills.get()` 保持策略无关,因为可信内部调用方可能需要任意定义;对外消费方则必须在展示或加载 skill 之前执行自身对应的判定函数。模型工具和 TUI 会在调用 `get()` 前检查与调用策略无关的摘要,随后再次检查已加载的定义:被拒绝的名称绝不会进入定义加载流程,发现与加载之间发生策略变更也无法暴露该 skill 的正文。
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地根据用户字段执行策略,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
这些规则允许以下四种组合:
@@ -39,12 +39,12 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
**在 `ctx.skills.get()` 内执行调用策略。** 不予采纳,因为 `get()` 无法判断调用方是模型工具、人类命令还是可信编排逻辑。在此处过滤还会使两个接口均禁止调用的组合无法被检查或管理。
**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 契约,而本仓库尚未发布,无需承担兼容义务。快速失败可以避免暗中保留不符合标准的拼写。
**将驼峰式 frontmatter 作为别名处理。** 不予采纳,因为外部格式遵循采用 kebab-case 的 Claude skills 约定,而本仓库尚未发布,无需承担兼容义务。显式失败可以避免暗中保留不符合标准的拼写。
**增加由浏览器端直接调用 skill 的 RPC。** 本次改动不予采纳,因为现有浏览器流程插入的是模型引用,而非已经加载的指令正文。因此,该流程应当取模型与用户调用策略的交集;直接由用户加载的接口需要单独设计协议与日志记录方式。
## 后果
提供方与运行时注册对外提供小而类型明确的调用契约,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。
提供方与运行时注册对外提供小而类型明确的调用约定,同时本地 YAML 仍可扩展。每个新的发现消费方都必须明确选择模型判定函数、用户判定函数、两者的交集,或可信且不过滤的访问方式;如果遗漏这项选择,评审时可以直接看出问题,而不会再被注册表行为掩盖。
无密钥 ACP(Agent Client Protocol)快照固定了模型目录的变更:其中包含仅允许模型调用的 skill,并排除仅允许用户调用的 skill。组装后的无密钥 TUI 快照按精确名称发现并加载一个仅允许用户调用的 skill,随后在加载正文前拒绝一个仅允许模型调用的 skill;真实 Loader/PTY 冒烟测试通过随产品交付的终端进程证明了同一条仅允许用户调用的路径。真实宿主上的 Chromium 快照固定了浏览器在全部四种策略组合下的交集行为。TUI 单元测试覆盖这些组合以及资源释放竞态;注册表、本地解析器、模型工具和 API 代理测试则覆盖默认值、支持的布尔写法、格式错误的值、旧键拒绝、精确名称加载时的策略执行,以及浏览器侧的策略交集。

View File

@@ -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/feature/2026-07-28-web-terminal-card.md
2026-07-28-web-terminal-card.md: 026b32fed2c79533abfadc58314e2844ff556414
2026-07-28-web-terminal-card.zh.md: 4c74d880c5795cf85f32391b27e6ffd2c5074870
2026-07-28-web-terminal-card.zh.md: ff0b7f327560e9dba379a0d36596b267b739aaa7

View File

@@ -12,15 +12,15 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode
## Decision
`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图约定交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道 seam;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为约定把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
该组件的契约:
该组件的约定:
- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片以**自身左内边距**预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。该预留用 padding 而非 margin,是因为每个渲染点都会整条重写 `margin` 来设定自己的缩进——那会静默取消基于 margin 的落区,并让容器把状态点裁掉。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法保留原 TUI transcript 折叠卡片的行为,因此既有的首尾选择保持稳定。
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。
- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法保留原 TUI transcript(文本记录)折叠卡片的行为,因此既有的首尾选择保持稳定。
- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因,以及 SGR 状态会从一行延续到下一行、而不是在每个换行处关闭的原因。行内擦除属于同一次重放,因为 `\r\x1b[K` 是每个 spinner 与进度条都会写的同一个惯用法——只建模 `\r` 会让上一帧的尾巴留在原处,那是终端从未显示过的文本。只有 `m` 会累加进单元格样式;光标或擦除序列不能累加,否则状态串会随每次重绘线性增长,并发出 anser 只能丢弃的边界。SGR 按单元格以**归一化记录**保存(前景、背景、属性集合),而不是序列历史:累积原始序列会让每个状态边界重新发射整条链,因此不做完整 reset 的换色输出会发射 O(n^2) 个字符——3200 个这样的单元格产生 25 MB 并最终触发 `RangeError`,远低于 bash 自身的输出上限。该记录也让所有 chalk 系工具写出的属性闭合码(`39`、`49`、`22`、`24` 等)真正闭合其属性,且每个边界只为它开启的状态发射一条规范序列。一个分段也必须**收束**:重放收敛到扫描结束时的状态,而不是最后一个被写入单元格的状态——因为最后一次写入之后的 reset 不改变任何单元格,却结束了该分段;没有这一步,以 `\x1b[0m` 结尾的行会把颜色泄漏到其后所有行。光标按终端列推进,因此制表符前进到下一个 8 列制表位、宽字符占两列(其续列在首列被覆盖后变为空白而非合拢),组合标记不占列。宽度依据 emoji **presentation** 而非 U+2600–U+27BF 整个区块:`\u2713`——每条进度行都会写的对勾——只占一列,把该区块整体当作双宽恰好会错位这张卡片赖以存在的那类输出。写入宽字符对的任一半都会把另一半清成空白,因为终端无法让一个双格字形只留下一格:`a\tb` 之后用 `XY` 重绘显示为 `XY b`,因为两个字符的重绘到不了第 8 列。
- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字——该判定读的是卡片实际渲染的解析行,而非原始文本,因为只含转义或控制字节的输出能通过 `trim()` 却解析不出任何可见内容,否则就会画出一片空行外加一个把不可见字节写进剪贴板的复制控件。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
@@ -41,7 +41,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode
**复用 `CodeBlock` 并传入 `console` 语言,而不新建组件。** 已否决:`CodeBlock` 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它清过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它通过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
## Consequences
@@ -49,7 +49,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode
`TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于那道接缝自身的改动。
在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于那道 seam 自身的改动。
内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
@@ -59,7 +59,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode
`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮次改名为 `bash`、第 60 轮次保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮次的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮次有意排在 todo 轮次**之前**:当前计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮次还承载第 60 轮次两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且它位于卡片盒之内、提示标签之左——这正是那道 gutter 内边距所拥有的不变量;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `terminal-card.expected.md` 基准记录了提示行中已解析的 workspace——这正是不带 `workdir` 的 bash 调用应当显示的内容,而非一个裸 `$`。

View File

@@ -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/feature/2026-07-30-config-only-repository-plugins.md
2026-07-30-config-only-repository-plugins.md: 2057125fc78596dd4e5eb153f77b828f83d9ceff
2026-07-30-config-only-repository-plugins.zh.md: 6e741b46be716e21a11c2f508fb5e6d0505c76d0
2026-07-30-config-only-repository-plugins.zh.md: afda636c6592e26e80cd2f8f665f92d3010f28f7

View File

@@ -12,19 +12,19 @@ Status: implemented
已交付的 TUI 和 Web/无头 `cordis.yml` 配置树包含一个空的 `repository-plugins` 配置项。用户只需修改 `$DSH_HOME/config.yaml`,用 `repositories` 列表替换该配置项的配置。每一项采用 `github:owner/repository#<ref>`,并可追加 `&path:/.../.dsh-plugin`;省略时选择 `/.dsh-plugin`。必须显式指定 ref;路径是仓库内的绝对路径,并以 `.dsh-plugin` 结尾;重复的规范化说明符在安装前即被拒绝。不提供插件市场、发现索引、HTTPS URL 词汇或隐式的最新版本。
`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包(package),运行包括 `prepare` 在内的普通生命周期,并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装模块并将其挂载为子 fiber,因此 skill(技能)与 MCP 仍沿用格式包定义的所有者、失败契约和清理行为。
`@deepseek-ai/dsh-repository-plugin` 校验并规范化每个源,再通过 vendor 中的通用 [`RepositoryCache`](../architecture/2026-07-30-package-manager-native-repository-cache.md) 解析。默认缓存位于 `$DSH_HOME/cache/repository-plugins`;`cacheDir` 是显式的部署覆盖项。随应用提供的 pnpm 选择配置的仓库子包,运行包括 `prepare` 在内的普通生命周期,并原子发布该精确说明符。DSH 宿主只导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber,因此 skill(技能)与 MCP 仍沿用格式包定义的所有者、失败约定和清理行为。
## 实时更新与失败
`dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头界面在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。
`dsh-app-boot` 通过一个辅助函数挂载根 Include,并保留其确切的 Loader `Entry`。TUI 和 Web 通过 Cordis HMR(热模块替换)注册 `$DSH_HOME/config.yaml`;无头模式在启动时读取同一文件,但不保留监视器。监视器更新会重新构建 Include 补丁列表,先放置不可变的应用自有补丁,再放置新解析的个人补丁。因此,Web 生成的端口、会话根目录、信任和前端值会在每次个人编辑后保留,除非后续个人补丁有意替换相应配置项。
Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装模块导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播并行的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。
Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader 以事务方式协调候选配置:成功时提交新源列表;拉取、准备、包装层导入、格式或子插件失败时拒绝候选配置,并保留或恢复最后一个可用树。HMR 会把捕获的值规范化为 `Error`,记录错误,并广播相应的 `hmr/config-update-failed(filename, error)` 事件;观察者失败不会中断刷新处理。MCP 传输连接失败仍沿用现有 MCP 客户端所收束的「插件成功加载但无工具」结果,因此不会被重新分类为配置更新失败。
相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。
## 信任边界
配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装模块会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。
配置仓库即授权该仓库及其依赖中的包管理器生命周期代码以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。固定的运行时包装层会阻止仓库作者提供的 Cordis 入口成为受支持插件格式的一部分;它无法让包准备过程安全执行不受信任的代码。
## 考虑过的替代方案
@@ -36,7 +36,7 @@ Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader
**监视缓存内容,或自动刷新相同 ref。** 否决,因为一个配置值必须标识一个不可变的已准备版本。后台远端解析会在没有配置差异的情况下改变可执行代码,并使回滚依赖可变的远端状态。
**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定契约,并在需要时把原始值保留为错误原因。
**广播 `unknown` 失败载荷。** 在 HMR 边界否决。JavaScript 内部可以抛出任意值,但公开事件始终接收规范化的 `Error`,从而为观察者提供稳定约定,并在需要时把原始值保留为错误原因。
## 后果
@@ -47,4 +47,4 @@ Cordis 会串行处理并合并该确切路径上的变更。Include 与 Loader
## 测试
仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装模块加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。
仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装层加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。

View File

@@ -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/feature/2026-07-30-continuable-subagent-report-tool.md
2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344
2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f
2026-07-30-continuable-subagent-report-tool.zh.md: 7adc4acc470bd701eee756e7d9ef89d287852a57

View File

@@ -1,4 +1,4 @@
# Agent Note(agent 决策记录):可继续 subagent 报告工具
# Agent Note: 可继续 subagent 报告工具
Status: implemented
@@ -8,15 +8,15 @@ Status: implemented
可继续的进程内 subagent 能够接收 parent 后续发来的消息、保留后代、结算并冷恢复,但基础生命周期无法让它们将选中内容发送给直接 parent。child 的完整输出已可从持久化会话中重建,因此缺失的能力是显式投递,而非结果存储。
如果将每条 assistant 最终消息都视为隐式结果,就会混淆轮次完成与报告。长期运行的 child 可能在某个轮次中无内容可报告,也可能在另一个轮次多次报告进展,而且报告后必须仍可继续工作。因此,接收方权限、静默投递与唤醒投递、确认、持久性和重试行为都需要一份显式契约。
如果将每条 assistant 最终消息都视为隐式结果,就会混淆轮次完成与报告。长期运行的 child 可能在某个轮次中无内容可报告,也可能在另一个轮次多次报告进展,而且报告后必须仍可继续工作。因此,接收方权限、静默投递与唤醒投递、确认、持久性和重试行为都需要一份显式约定。
## 决策
新增可独立安装的 `@deepseek-ai/dsh-tool-subagent-report` 包(package)。它会向每个可继续进程内 child Activation 贡献一个普通的面向模型 `report` 工具。child 在一个轮次中可调用零次或多次。调用成功既不会结束该轮次或结算 Activation,也不会阻止 parent 之后继续 follow-up;完成轮次也绝不会自动报告。
新增可独立安装的 `@deepseek-ai/dsh-tool-subagent-report` 包。它会向每个可继续进程内 child Activation 贡献一个普通的面向模型 `report` 工具。child 在一个轮次中可调用零次或多次。调用成功既不会结束该轮次或结算 Activation,也不会阻止 parent 之后继续 follow-up;完成轮次也绝不会自动报告。
该功能是协作控制,不是承载结果的执行包装层。它不新增 Task、`SubagentRun`、结果 promise、Activation 状态、投递队列或回放路径。
### 面向模型的契约
### 面向模型的约定
`report` 只接受 `{ output: string }`,也只返回 `{ messageId: string }`。它不接受 child id、接收方 id 或投递模式。`exec.agent` 将工具调用绑定到发送报告的 child;服务从持久化 `parentSession` 中推导唯一接收方,调度则由部署配置决定。
@@ -24,7 +24,7 @@ Status: implemented
工具描述会明确报告操作是显式、可重复、仅限直接 parent 且不会结束轮次的。它还会警告:发送被接受后,后续 `tools/post-execute` 失败可能替换工具结果,因此工具结果失败时内容仍可能已经送达。没有幂等键时,更强的表述会诱导调用方在结果不明确的失败后重复重试。
该工具使用不带 location 的通用渲染,其确认中包含 `messageId`。作用域局部注册使呈现与执行保持一致:root、one-shot child、远程提供方、同级作用域和无 agent 执行既不能看到,也不能执行 `report`。它会在 child 的全局 `toolFilter` 之后安装,因此委派 allow-list 不会意外移除这条结构性返回通道;不需要返回通道的部署不安装该包。
该工具使用不带 location 的通用渲染,其确认中包含 `messageId`。作用域局部注册使呈现与执行保持一致:root、one-shot child、远程提供方、同级作用域和无 agent(智能体)执行既不能看到,也不能执行 `report`。它会在 child 的全局 `toolFilter` 之后安装,因此委派 allow-list 不会意外移除这条结构性返回通道;不需要返回通道的部署不安装该包。
### 服务权限
@@ -40,7 +40,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以
静默投递调用 `parent.inject()`。它会添加模型可见上下文,但不启动 parent 模型请求:若 parent 空闲,则在调用返回前追加消息;若 parent 正在准入或运行,则暂存报告,留到下一个安全日志位置。该模式不创建 inbox 条目实例,因此也不会产生虚构的继续执行管理器接受记录。
唤醒投递调用 `parent.followup()`。它会创建一个普通的 FIFO parent 轮次,唤醒已驻留的 parent driver,且绝不 steering 已开始的轮次。当该 parent 本身也是可继续 Activation 时,发送会使用管理器现有的准入计数,防止 parent 在同步入队与准入微任务之间结算。
唤醒投递调用 `parent.followup()`。它会创建一个普通的 FIFO parent 轮次,唤醒已停驻的 parent driver,且绝不 steering(中途引导)已开始的轮次。当该 parent 本身也是可继续 Activation 时,发送会使用管理器现有的准入计数,防止 parent 在同步入队与准入微任务之间结算。
两种模式都会将一条用户角色消息封装为 `Background subagent <child-id> reported:`,后面跟随完全原样的 `output`。持久化来源信息为 `{ kind: 'subagent-report', senderSessionId: child.id }`。并发发送的顺序由 Agent 的常规规则决定;subagent 层不会创建第二条队列。
@@ -48,13 +48,13 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以
成功表示确切的在线 parent 已同步接受该消息。空闲 parent 在接受静默注入时已经完成追加,而暂存的静默上下文只有到达正常日志边界后才可重建。唤醒投递包含一个 inbox 条目实例,其 id 与返回的稳定消息 id 保持分离。
首个版本不提供持久化邮箱、幂等键、投递回执、重试协议或恰好一次保证。进程故障可能让调用方无法确定结果,在结果未知时重试则可能重复报告。parent 不可用时,持久化 child transcript 仍是恢复来源。
首个版本不提供持久化邮箱、幂等键、投递回执、重试协议或恰好一次保证。进程故障可能让调用方无法确定结果,在结果未知时重试则可能重复报告。parent 不可用时,持久化 child transcript(文本记录)仍是恢复来源。
### 组合与生命周期
subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。
注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。
注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与会话发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose(资源释放)与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。
该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。
@@ -96,7 +96,7 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`,
### 在 Agent 创建后校验 setup
创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。
创建完成后的撤销检查只能在 Agent 与会话均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。
## 影响
@@ -115,4 +115,4 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`,
wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。
注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。
注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未完成其作用域清理,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。

View File

@@ -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/feature/2026-07-30-current-sandbox-policy-context.md
2026-07-30-current-sandbox-policy-context.md: 093f9a8a0b58256bb4f2301cc62fa83629de4fbd
2026-07-30-current-sandbox-policy-context.zh.md: 729ea9d50c7dbbfa027e1cad11929ae1c2b379b1
2026-07-30-current-sandbox-policy-context.zh.md: ce7ff2d0cff0fc1068995fde174a4eb1df5343b7

View File

@@ -18,7 +18,7 @@ Status: implemented
现有 `dsh-system-prompt` 组装在稳定系统段与工具 schema 之外,还包含有序的动态上下文。组装一个步骤后,agent loop(智能体循环)会将所有活动上下文渲染成一份带显式取代声明的完整快照。仅当不存在保留快照、字节发生变化、压缩(compaction)移除了保留消息,或最后一项贡献消失而需要一份清除快照时,它才会追加一条带来源的 `user/message`。快照追加在现有历史之后、`step/start` 之前,因此策略变化时仍会保留此前的系统与对话缓存前缀。会话事件本身可以重建确切的模型输入;只有策略上下文变化时,`request/header` 仍逐字节相同。
归属范围保持收敛。批准策略会将其完整的当前 `ask` 或 `never` 事实贡献给同一份完整快照;只迁移沙箱无法保留缓存,因为 `/permission` 会同时改变两方。计划模式仍由 `plan:policy` 负责,工具插件也继续负责各自的 schema,以及尝试、拒绝与升级引导。上下文负责说明常驻策略;文件系统、一次性 bash 与终端后端仍是强制执行边界。
归属范围仍然有限。批准策略会将其完整的当前 `ask` 或 `never` 事实贡献给同一份完整快照;只迁移沙箱无法保留缓存,因为 `/permission` 会同时改变两方。计划模式仍由 `plan:policy` 负责,工具插件也继续负责各自的 schema,以及尝试、拒绝与升权引导。上下文负责说明常驻策略;文件系统、一次性 bash 与终端后端仍是强制执行边界。
缓存决策依据当前源码,而不只依靠类比。Codex 将权限建模为 developer 角色的 `WorldState` 段并保存其指纹;只有状态变化或保留的历史丢失该片段时才发出它,同时记录快照转换。Hermes 在会话期间保持系统提示词不变,并明确将不断变化的 skill(技能)、模型与语音通知前置到下一条用户消息,以免提示词缓存失效。Pi 没有可比的内置沙箱状态,Claude Code 当前的原生实现也无法公开检视;不过,Anthropic 的公开缓存指南仍将不断变化的逐请求上下文放在稳定缓存前缀之后。
@@ -26,7 +26,7 @@ Status: implemented
## 措辞证据
措辞实验预先登记「预防性拒绝」为主要终点,并要求旧常驻句子在十二个 fresh session 中至少产生一次拒绝,之后才能评判任何替代措辞。2026-07-30,commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` 通过已交付的 Web 组合运行 `deepseek-v4-flash`,使用精确的阳性对照句子 `Bash commands run under the "read-only" file sandbox.` 与当前工具归属方的尝试引导。对照组产生零次预防性拒绝和零次推测性升级;十二个会话全部先发起普通 bash 调用、观察到拒绝、在同一轮次升级、获得批准,并让所请求文件实际落盘。没有样本被排除。
措辞实验预先登记「预防性拒绝」为主要终点,并要求旧常驻句子在十二个 fresh session 中至少产生一次拒绝,之后才能评判任何替代措辞。2026-07-30,commit `2bf41990401b194bd8637f07bbd90c67a9eeac75` 通过已交付的 Web 组合运行 `deepseek-v4-flash`,使用精确的阳性对照句子 `Bash commands run under the "read-only" file sandbox.` 与当前工具归属方的尝试引导。对照组产生零次预防性拒绝和零次推测性升权;十二个会话全部先发起普通 bash 调用、观察到拒绝、在同一轮次升权、获得批准,并让所请求文件实际落盘。没有样本被排除。
缓存安全交付变更后,commit `10d4e0ff7b68d38fc4403403b644aac442b97a00` 通过新的尾部上下文通道重复了同一项十二会话阳性对照。结果再次为零次预防性拒绝和零次推测性升权;十二个会话的首次调用均为普通调用,随后观察到拒绝、在同一轮次升权并获得批准。其中八个会话让所请求文件按确切要求落盘,没有样本被排除。
@@ -42,7 +42,7 @@ Status: implemented
**把当前策略放入动态系统段。** 不予采用,因为真实提供方证据显示,首次权限切换后缓存读取降至 256 个 token,而约 14.7k 个输入 token 未命中缓存。DeepSeek 匹配完整前缀;改变第一条 wire 消息会阻止复用更长的系统与历史前缀。
**由每个策略归属方独立调用 `agent.inject()`。** 不予采用,因为同级监听器的顺序会决定模型所见顺序,分开的消息可能暴露不匹配的中间快照,并且每个归属方都需要各自扫描压缩后的保留状态。现有组装归属方可以对贡献排序,并具体化一份原子化的完整快照。
**由每个策略归属方独立调用 `agent.inject()`。** 不予采用,因为同级监听器的顺序会决定模型所见顺序,分开的消息可能暴露不匹配的中间快照,并且每个归属方都需要各自扫描压缩后的保留状态。现有组装归属方可以对贡献排序,并生成一份原子化的完整快照。
**通用运行时事实包。** 不予采用,因为现有系统提示词组装已经拥有段、schema、变量、作用域和权威的逐步骤 waterfall(瀑布式事件)。为该归属方增加有序上下文,无需新增包或第二个注册表服务。
@@ -50,7 +50,7 @@ Status: implemented
**缓存安全迁移后仍保留 Candidate A。** 不予采用,因为中性的真实提供方任务中,尽管已有 bash 尝试引导,模型仍以纯文本拒绝,且没有调用工具。保留下来的反预防性拒绝原则本身不说明任何升权机制;它告诉模型不要从常驻标签推断操作不可能完成,然后把拒绝与升权行为交还给可用工具。
**继续省略沙箱模式,因为常驻模式标签曾引发预防性拒绝。** 不予采用,因为新的 Web 请求否则会暴露变更工具,却隐去这些工具的常驻策略,导致模型在首次操作前错误声称自身能力。先前的线上测量仍是必须执行的反证测试:使用 `Bash commands run under the "read-only" file sandbox.` 时,十二个轮次中有五个没有调用工具。已提交的工具归属方尝试引导晚于该测量,因此应通过当前工具契约下的新阳性对照实验选择替代文案,而不能假设旧条件与当前条件相同。
**继续省略沙箱模式,因为常驻模式标签曾引发预防性拒绝。** 不予采用,因为新的 Web 请求否则会暴露变更工具,却隐去这些工具的常驻策略,导致模型在首次操作前错误声称自身能力。先前的线上测量仍是必须执行的反证测试:使用 `Bash commands run under the "read-only" file sandbox.` 时,十二个轮次中有五个没有调用工具。已提交的工具归属方尝试引导晚于该测量,因此应通过当前工具约定下的新阳性对照实验选择替代文案,而不能假设旧条件与当前条件相同。
**独立的模型上下文包。** 不予采用,因为策略归属方可以直接解析当前会话状态,现有组装服务也可以对其排序。新包只会围绕同一个请求边界引入浅层组合 seam 和额外的文档/门禁表面。
@@ -60,4 +60,4 @@ Status: implemented
模型在试探工具前就会收到常驻文件策略,且 `/permission` 后的下一个请求会反映已提交的模式。稳定的系统提示词不再随沙箱或批准状态变化;变化后的完整上下文快照会在保留的历史之后仅追加,状态不变时不增加消息。较旧的快照仍保留在历史中,但最新的完整快照会明确取代它们。该声明是引导,而不是强制执行护栏:运行时安全仍来自文件系统、一次性 bash 与终端后端消费同一项解析完成的策略。
聚焦测试固定了所有模式、规范化根目录、切换时机、服务释放、上下文顺序、清除、稳定的请求 header、恢复,以及不同 `TMPDIR` 值下的字节稳定性。无密钥的组装快照通过真实 Loader 组合固定持久上下文消息。无密钥回放负责固定中性的拒绝到升级轨迹;它是结构回归证明,而不是措辞选型证据。
聚焦测试固定了所有模式、规范化根目录、切换时机、服务释放、上下文顺序、清除、稳定的请求 header、恢复,以及不同 `TMPDIR` 值下的字节稳定性。无密钥的组装快照通过真实 Loader 组合固定持久上下文消息。无密钥回放负责固定中性的拒绝到升权轨迹;它是结构回归证明,而不是措辞选型证据。

View File

@@ -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/feature/2026-07-30-deepseek-onboarding-credential-setup.md
2026-07-30-deepseek-onboarding-credential-setup.md: 419ea0aea56e82e301189d90d5ca78495da2da71
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 936402c9ed83eccc3d5e78a57247f06347522c0f
2026-07-30-deepseek-onboarding-credential-setup.zh.md: 46d23d40bc689a2827721e8b5cef47825355266f

View File

@@ -12,15 +12,15 @@ Status: implemented
**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。
**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为契约;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
**设置外壳只贡献排序与导航,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 通过 `slots.inject()` 注册 DeepSeek 步骤及其 Models 分区,使每项贡献都跟随自身的声明生命周期,不让插件加载顺序成为约定;独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。
**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。设置或凭据能力不可用时,界面会保留相应的部署诊断,并将用户引导至同一页面;适配器缺失时则仍跳过该步骤,因为导航无法挂载 Cordis 插件。
**不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。
**不可用状态不会占住产品。**可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。
## 曾考虑的替代方案
**为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 槽位的伴随信息、凭据引用及失效事件顺序。
**为首次使用引导单设 store 与就绪状态 RPC 调用序列**:不予采用,因为这会在 Models 页之外,再建立一套客户端解释,用于判定提供方身份、设置路径、secret 伴随文件、凭据引用及失效事件顺序。
**在首次使用引导中增设第二个 API key 编辑器**:不予采用,因为 Models 页已为这一状态渲染 DeepSeek 设置卡片。复制其中的 secret 草稿、写入错误处理和已配置状态收敛会增加第二个安全敏感的 UI,却不会带来新的用户能力。

View File

@@ -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/feature/2026-07-30-queued-manual-compaction.md
2026-07-30-queued-manual-compaction.md: 5100808ada4b7b284228113584e577d46ff91101
2026-07-30-queued-manual-compaction.zh.md: 29c3b921d0d59527aa1d8af1c91d085b52045377
2026-07-30-queued-manual-compaction.zh.md: c995fc59fda83de639b6e1a603448243ba12c505

View File

@@ -30,7 +30,7 @@ Status: implemented
维护阶段不会创建第二个队列。之后发送的项保留其 `MessageId`、位置、FIFO 顺序与唤醒信息。会唤醒的输入会保持排队,直至维护任务结算,再启动既有驱动器路径;`inject()` 仍然不会唤醒驱动器。
`whenIdle()` 会把维护任务及其结算后释放的所有唤醒工作视为尚未完成的活动。取消会中止 agent 自有的维护信号,生命周期 teardown 则会在 dispose(资源释放)完成前排空同一个活动边界。
`whenIdle()` 会把维护任务及其结算后释放的所有唤醒工作视为尚未完成的活动。取消会中止 agent(智能体)自有的维护信号,生命周期 teardown 则会在 dispose(资源释放)完成前排空同一个活动边界。
### 一个参数化事务拥有每一对标记
@@ -89,7 +89,7 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试
**同时使用持久标记与进程本地 mutex。** 不予采用,因为两项权威在回放后可能产生分歧,还会要求用包装层分支处理标记对已经表达的状态。
**与唤醒提示词一起阻塞注入。** 不予采用,因为按契约,空闲注入是不会唤醒的持久上下文;延迟注入会使插件排序依赖某个 UI 命令。
**与唤醒提示词一起阻塞注入。** 不予采用,因为按约定,空闲注入是不会唤醒的持久上下文;延迟注入会使插件排序依赖某个 UI 命令。
**要求标记区间只包含压缩事件。** 不予采用,因为标记表示锁的时间点。溯源信息会精确指明所选 seq 与被遮蔽 seq;排他性不会增加正确性,只会拒绝有效注入。
@@ -97,9 +97,9 @@ DSH 有意在调用摘要器前记录 `compact/start`。缓慢或崩溃的尝试
## 验证
Agent loop 测试覆盖同一 tick 内的优先权、保留 ID 与 FIFO 生命周期、会唤醒和静默的排队工作、幂等释放、`whenIdle()`、取消与 teardown。压缩测试覆盖独立与数字形式的不变量 owner、end-seed 回放、活动与陈旧未匹配标记、listener 重入、所选 span 漂移、commit 与闭合失败、flush 顺序、原始取消原因、raw output 与 usage 保留,以及自动/手动互斥。
agent loop(智能体循环)测试覆盖同一 tick 内的优先权、保留 ID 与 FIFO 生命周期、会唤醒和静默的排队工作、幂等释放、`whenIdle()`、取消与 teardown。压缩测试覆盖独立与数字形式的不变量 owner、end-seed 回放、活动与陈旧未匹配标记、listener 重入、所选 span 漂移、commit 与闭合失败、flush 顺序、原始取消原因、raw output 与 usage 保留,以及自动/手动互斥。
命令包固定注册行为、Loader 组合、参数拒绝、精确的成功/失败文本、取消、不进入模型历史的保证,以及处置操作在中止使执行器停止等待处理器后,仍会跨越相互独立的闭合与 flush 边界等待该处理器结算。客户端运行时投影测试固定 end-seed 中断,以及随后一次独立尝试的完成。`queued-manual-compact` 终端快照通过已组装 TUI 驱动真实按键:`/help` 可发现该命令;被暂停的摘要会接纳一个排队提示词和即时注入;`turn: null` 标记与 flush 先于排队提示词轮次;命令生命周期保持纯日志;派生顺序固定为检查点 → 注入 → 排队提示词。
命令包固定注册行为、Loader 组合、参数拒绝、精确的成功/失败文本、取消、不进入模型历史的保证,以及 dispose 在中止使执行器停止等待处理器后,仍会跨越相互独立的闭合与 flush 边界等待该处理器结算。客户端运行时投影测试固定 end-seed 中断,以及随后一次独立尝试的完成。`queued-manual-compact` 终端快照通过已组装 TUI 驱动真实按键:`/help` 可发现该命令;被暂停的摘要会接纳一个排队提示词和即时注入;`turn: null` 标记与 flush 先于排队提示词轮次;命令生命周期保持纯日志;派生顺序固定为检查点 → 注入 → 排队提示词。
## 后果

View File

@@ -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/feature/2026-07-30-search-render-card.md
2026-07-30-search-render-card.md: 29544cb703f3ab048f4e7702935887ecf05179bf
2026-07-30-search-render-card.zh.md: e0ae21924e82152ba629ab628476113ad9afe3d8
2026-07-30-search-render-card.zh.md: e4cb39f562ea54b03e2b502ec19a90571c22e5e3

View File

@@ -1,4 +1,4 @@
# Agent Note:搜索渲染意图 —— grep 与 glob 产出结构化搜索卡片
# Agent Note: 搜索渲染意图 —— grep 与 glob 产出结构化搜索卡片
Status: implemented
@@ -8,7 +8,7 @@ Status: implemented
`grep` 与 `glob` 返回结构化的 canonical 值 —— `grep` 是扁平的 `{ matches: [{ path, lineNumber, line }] }`,`glob` 是 `{ paths: string[] }` —— 但每个 UI 只见过它们面向模型的渲染文本:`grep` 把匹配按文件头分组、每行 `Line N:`,`glob` 打印换行连接的路径列表,两者在内联上限(`grepMaxMatches`,默认 250;`globMaxResults`,默认 100)把后续结果落到 spill 文件时都追加一个 spill 脚注。想把搜索结果渲染成可展开的按文件匹配组、或可选择的路径列表的 web 前端,只能去重新解析那段文本。两个工具都已声明调用时的[渲染意图](../architecture/2026-07-02-tool-render-intent-union.md)(`GenericCallView`,`kind: 'search'`),但没有结果时视图,所以已完成的调用回退到渲染原始文本的 generic 卡片。
结构化 canonical 值不跨线传输:只有面向模型的渲染文本、以及当工具声明了 `output.presentationMeta` 时的一份 JSON 元数据,会经 `tool/result` 事件到达客户端([canonical-output 契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果时视图必须把数据投影进 `presentationMeta`,再在 `presentResult` 里读回 —— 与 `write`/`edit` 的 diff 卡片走同一条路。
结构化 canonical 值不跨线传输:只有面向模型的渲染文本、以及当工具声明了 `output.presentationMeta` 时的一份 JSON 元数据,会经 `tool/result` 事件到达客户端([canonical-output 约定](../architecture/2026-07-20-canonical-tool-output-contract.md))。因此携带结构化数据的结果时视图必须把数据投影进 `presentationMeta`,再在 `presentResult` 里读回 —— 与 `write`/`edit` 的 diff 卡片走同一条路。
## 决定
@@ -30,7 +30,7 @@ Status: implemented
`SearchMeta` 的成员形状是对象字面量 `type` 别名,而非视图暴露的 `SearchFileMatches`/`SearchLineMatch` 接口,因为只有 type 别名可赋给 `presentationMeta` 返回的 `JsonValue` 索引签名;两者结构等价,所以投影值仍读回为 `SearchResultView`。
没有专用 `search` 分支的消费方会回退到同一个 generic body,并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR 之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端契约及其两个生产者。
没有专用 `search` 分支的消费方会回退到同一个 generic body,并从原始结果中读取面向模型的文本。因为搜索视图不带自己的 `content`,而本 PR(Pull Request)之前 grep/glob 返回的是 generic 卡片,所以该回退与引入 search 卡片之前的路径逐字节一致。渲染结构化 `files`/`paths` 形状的前端独立于这个后端约定及其两个生产者。
## 考虑过的备选
@@ -40,7 +40,7 @@ Status: implemented
**把面向模型的文本作为视图的 `content` 附上。** 否决:对每个当前消费方是 no-op,且把整段搜索文本第二次序列化进持久化视图。视图是结构化形状;文本回退读原始结果内容。
**在 `PostToolDecision` 上加 meta 通道,让 `dsh-spill-policy` 像约束 `content` 那样约束 `meta`。** 本 PR 否决:它为一个工具的载荷改动核心工具决策契约与 spill-policy 插件。投影在配置字节上限处约束自己的 `meta` 是自包含的,且保持 seam 不变。
**在 `PostToolDecision` 上加 meta 通道,让 `dsh-spill-policy` 像约束 `content` 那样约束 `meta`。** 本 PR 否决:它为一个工具的载荷改动核心工具决策约定与 spill-policy 插件。投影在配置字节上限处约束自己的 `meta` 是自包含的,且保持 seam 不变。
**镜像 terminal 卡片双侧对称的调用时 `SearchCallView`。** 否决:搜索调用在 `execute` 前没有匹配或路径,视图只会携带 `GenericCallView` 已有的标题。
@@ -57,5 +57,5 @@ Status: implemented
## 相关
- [工具调用呈现的带标签渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 PR 用 `search` 结果标签扩展的 `card` 标签词汇。
- [Canonical 工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投影所乘的 value/render/`presentationMeta` 划分;结构化值留在执行本地,卡片乘 `meta`。
- [Canonical 工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 本投影所依托的 value/render/`presentationMeta` 划分;结构化值留在执行本地,卡片通过 `meta` 传输。
- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— 本 PR 在后端镜像的先例:工具把结果投影进 `presentationMeta` 与一个 `presentResult` 视图;搜索卡片的 web 消费方是与之类比的后续。

View File

@@ -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/feature/2026-07-30-versioned-gui-welcome-onboarding.md
2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be
2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc
2026-07-30-versioned-gui-welcome-onboarding.zh.md: 002a04b1fc6acc4fc7cfee053bdbbb2abaef047e

View File

@@ -14,11 +14,11 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。
**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。更新该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。
**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API Proxy(`@deepseek-ai/dsh-host-apiproxy`)在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。
**引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出节奏感,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。
## 曾考虑的替代方案
@@ -28,7 +28,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测
**在渲染或窗口关闭时持久化**:不予采用,因为看见通知不等于确认,窗口关闭事件也无法可靠送达。只有显式提交「继续」才能阻止通知在下次启动时再次显示。
**通用的公开设置暴露标志**:不予采用,因为一个产品 namespace 不足以证明应当扩大每个 settings 注册方的公开配置面。网关保留显式的封闭允许列表。
**通用的公开设置暴露标志**:不予采用,因为一个产品 namespace 不足以证明应当扩大每个 settings 注册方的公开配置面。该 API Proxy 保留显式的封闭允许列表。
## 后果

View File

@@ -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/feature/2026-07-30-web-diff-card.md
2026-07-30-web-diff-card.md: b078da8d0e688d705683599467bd98b5c6a0be48
2026-07-30-web-diff-card.zh.md: a9c710df99c1060bbf50b591b2f62120dc7dcbef
2026-07-30-web-diff-card.zh.md: bf9424a9baff7ec6d49949016be3e30d400f2dd1

View File

@@ -6,15 +6,15 @@ Status: implemented
## Problem
`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建该视图)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `<pre>`。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `<pre>`。`diffs` 载荷 —— result 的关键所在 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费方;这次让它成为 `diff` 渲染意图的消费方,复用同一套四层结构。
## Decision
`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/tool/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/tool/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时(包括 `card` 值为本客户端版本所不认识的值),以及已结算调用的 result view 是 generic 时,它返回 null——走通用路径;write/edit 的执行错误正是这样留在通用路径上的。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。
@@ -24,23 +24,23 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行
- **行终止符。** 每一侧的内容按 `TerminalBlock` 与 TUI 共用的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。
- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可辨别归属。
几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
几何、圆角、字体与 `CodeBlock`/`TerminalBlock` 保持一致,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面与阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
## Alternatives considered
**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR(Pull Request)保持为对既有契约的纯 Web 消费。
**git 式行号栏。** `FileDiff` 约定只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号栏需要后端约定改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR(Pull Request)保持为对既有约定的纯 Web 消费。
**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
## Consequences
`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具结果的形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费方。
## Testing
@@ -48,10 +48,10 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX
`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
fixture(测试前置数据)(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot 快照(`apps/web/tests/built-boot.snapshot.ts`)是启动装配冒烟测试,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身约定它不带 diff 行为断言,那由接线套件负责。
## Related
- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本文复用其内联输出决策与头尾上限算术。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费方。
- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。

View File

@@ -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/feature/2026-07-30-web-queue-steer-action.md
2026-07-30-web-queue-steer-action.md: b04095b81f499982c8680a2d3627d8e98a70d8ac
2026-07-30-web-queue-steer-action.zh.md: b04902b8a8a0d727b01aa6ba5562e12cc5d36c92
2026-07-30-web-queue-steer-action.zh.md: 1b46ec90a27fbc3c8f3660a7cbdccb78a21a2cfa

View File

@@ -1,4 +1,4 @@
# Agent Note(agent 决策记录):将 Web 已排队消息转为活动轮次的 steering(中途引导)
# Agent Note: 将 Web 已排队消息转为活动轮次的 steering(中途引导)
Status: implemented
@@ -6,21 +6,21 @@ Status: implemented
## 问题
Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 steer 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。
Web composer 原本会在 agent(智能体)运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 steer 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。
如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。
## 决策
### 产品契约
### 产品约定
普通会话中每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。
普通会话中每个非编辑态的 QueueDock 行都会提供名为「插话发送」的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。
触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `user/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。
running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。
Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。
Composer 对新输入采用另一套尽力而为约定。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。
### Agent 与生命周期边界
@@ -34,15 +34,15 @@ Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲
`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。
Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。
Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个注册表。
AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。
AgentLoop 认领待处理 steering 时,会在同步追加持久 `user/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久会话事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。
现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。
现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的约定:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该约定。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。
### 验证
AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `user/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。
AgentLoop 约定覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `user/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。
Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖按单次入队项完成的待处理到持久交接,包括重复的 `MessageId` 值;Web ARIA 快照则覆盖位于运行状态行之后且仅有复制的待处理 steering,以及带时钟、复制和 fork 的持久节点。

View File

@@ -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/feature/2026-07-30-web-read-card.md
2026-07-30-web-read-card.md: 26d1634b6be86666980e65f842de51c868c26efd
2026-07-30-web-read-card.zh.md: 749177e93e8e3b2e34aed46d1fe99226395a6686
2026-07-30-web-read-card.zh.md: f8d63cb8be8dc9e469aa6a29bdb7611634cbb861

View File

@@ -6,7 +6,7 @@ Status: implemented
## Problem
`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `<path>…</path><type>file</type><content>…</content>` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。
`read` 工具返回规范化输出对象 `{ path, offset, lines: [{ number, text }], totalLines }`,但它的展示层把这个结构压平了。`presentCall` 声明为 `GenericCallView`(`kind: 'read'`,一个跟随定位),`presentResult` 返回 `GenericResultView`,其唯一内容是剥掉 `<path>…</path><type>file</type><content>…</content>` 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 `N: ` 前缀烘焙进文本、文件语言未知、`totalLines` 丢失。具备相应能力的客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。
结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 `ContentBlock[]`(已渲染文本)加上一个不透明的 `meta`;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 `N: text` 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。
@@ -14,13 +14,13 @@ Status: implemented
给[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 新增第四个 `card` 标签 `read`——仅在结果侧。`ToolResultView` 增加 `ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }`;`ReadFileLine { number; text }` 是共享的行单元。`ToolCallView` 不动:待定状态仍是 `GenericCallView`(`kind: 'read'`),因为一次调用在 `execute` 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。
read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出契约](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同([规范化工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.md))。`presentationMeta` 对一次顶层 surface 调用运行一次,返回 `{ path, offset, lines, totalLines, lang? }` 作为会话校验并存储在结果 `meta` 上的 JSON,`presentResult` 在 live 和回放路径上都把该 meta 收窄回 `ReadResultView`。`offset`(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 `lines` 数组而 `totalLines` 为正;没有持久化的 `offset`,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 `N: text` 文本既有损又对截断脚注脆弱。
`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI 会通过自己的 generic/default card 分支渲染文件文本。原 TUI 证明了这条回退的必要性:它的非穷尽结果 switch 读取 `view.content`,而另一道 dim-Markdown 门控也必须接纳 `card: 'read'`。该前端随后被移除,但对任何没有结构化 read 卡片的消费方而言,content 回退仍是视图契约的一部分。
`presentResult` 在以下情况返回 `undefined`——即 generic 回退:meta 缺失或畸形(`readMetaFromMeta` 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 `meta`——有意走同一条 `undefined` 路径:客户端回退到原始 `result.content`,因此显示带 `<path>/<type>/<content>` 信封的原文,而非旧展示器返回的剥信封 generic card。这是 [pre-release 立场](../../../../AGENTS.md#pre-release-stance-foundation-over-blast-radius)下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR(Pull Request)已重录全部已发布的 fixture(测试前置数据),且会话格式不承诺向后兼容。在成功路径上,`presentResult` 在结构化字段之外携带 `content`(剥信封后的文本),因此不具备 read 能力的 UI 会通过自己的 generic/default card 分支渲染文件文本。原 TUI 证明了这条回退的必要性:它的非穷尽结果 switch 读取 `view.content`,而另一道 dim-Markdown 门控也必须接纳 `card: 'read'`。该前端随后被移除,但对任何没有结构化 read 卡片的消费方而言,content 回退仍是视图约定的一部分。
### 语言提示推导
`langFromPath`(在 `read-render.ts` 中)通过一张固定小表(`LANG_BY_EXTENSION`,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)、无扩展名(`/etc/hosts`)、结尾的点、以及任何未知扩展名——此时 card 省略 `lang`,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它是一行表项新增。
`langFromPath`(在 `read-render.ts` 中)通过一张固定小表(`LANG_BY_EXTENSION`,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 `undefined`:dotfile(`.gitignore`)、无扩展名(`/etc/hosts`)、结尾的点、以及任何未知扩展名——此时 card 省略 `lang`,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它只需新增一行表项。
## Alternatives considered
@@ -30,7 +30,7 @@ read 工具通过 `output.presentationMeta` 投影结构化窗口,这与 write
**把结构化窗口放进新服务或旁路通道而非 `meta`。** 已否决:`meta` 是既有的持久化展示通道(write/edit 的应用 diff 就搭它),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。
**用 merge-extensible union 而非封闭标签。** 出于[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费者静默丢弃的变体比编译错误更糟。把 `read` 加入封闭 union 是扩展它的许可方式——每个在 `card` 上 switch 的消费者都继续编译,因为新成员落入其 generic default,而想要富视图的消费者新增自己的分支。
**用 merge-extensible union 而非封闭标签。** 出于[渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费方静默丢弃的变体比编译错误更糟。把 `read` 加入封闭 union 是扩展它的许可方式——每个在 `card` 上 switch 的消费方都继续编译,因为新成员落入其 generic default,而想要富视图的消费方新增自己的分支。
## Consequences
@@ -40,10 +40,10 @@ read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已
## Testing
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。当时的终端快照还钉住了消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript 则属于消费它的前端变更。
`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名(不区分大小写)、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的不透明 `meta` 边界——类型正确的回放 JSON 仍可能携带的语义无效情况:不是 1-based 整数的 `offset`、小于 `offset` 的首行 `number`、不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况;并且收窄正 `offset` 处的空窗口(字节上限低于首个选中行))。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP(Agent Client Protocol)会话 fixture(`fs-read`、`fs-read-window`、`fs-edit`、`fs-policy-reject`、`fs-write-overwrite`、`parallel-tool-calls`、`workspace-context`、`workspace-edit`)钉住持久化的读取 `meta`(含 `{{cwd}}` 令牌化路径),`cordis-inspect-jsdoc` 钉住四成员的 `ToolResultView` 联合类型。当时的终端快照还钉住了消费方的 generic dim-Markdown 回退保持逐字节一致;结构化卡片自身的组装应用 transcript(文本记录) 则属于消费它的前端变更。
## Related
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` 标签词汇。
- [Canonical tool output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用来投影 read 窗口的 `presentationMeta` 持久化通道。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card 遵循相同的生产者模式,仅结果侧。
- [工具调用展示的带标签渲染意图 union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本 Note 以 `read` 结果分支扩展的 `card` 标签词汇。
- [规范化工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.md) —— 拥有本 Note 用来投影 read 窗口的 `presentationMeta` 持久化通道。
- [Web 终端 card](2026-07-28-web-terminal-card.md) —— 客户端消费结构化 card 的先例;read card 遵循相同的生产者模式,仅结果侧。

View File

@@ -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/feature/2026-07-30-web-result-card-frontend.md
2026-07-30-web-result-card-frontend.md: c7e63220d824cf0bd3ac3536c528707d0db379bc
2026-07-30-web-result-card-frontend.zh.md: 4cec11371e8d0848f2f03ded0b093d80cc25a7a5
2026-07-30-web-result-card-frontend.zh.md: 6bb31797a7658adae267d034d0a6d9fa49dfe09f

View File

@@ -1,4 +1,4 @@
# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
# Agent Note: Web result 卡片前端——在浏览器渲染 web 渲染意图
Status: implemented
@@ -6,31 +6,31 @@ Status: implemented
## Problem
`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个以 `kind` 为标签的联合类型,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是约定笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
## Decision
`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/tool/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/tool/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它会在以下情况下返回 null,从而走通用路径:调用仍在运行(web 卡片是 result-only 的,因为工具保留 generic pending 视图);调用已结算,但其 result view 不是 web 卡片,包括本客户端版本不认识的 `card` 值(它经 wire 抵达,因而不能被信任为已编译的变体);result view 是 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留);web 卡片的 `kind` 是本客户端版本不认识的值(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是约定把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集**。MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。整份 source 列表渲染在单个 `<ol>` 里,由 `max-height: 320px` 与 `overflow-y: auto` 约束,因此高于该值的列表在原地纵向滚动,而不是把卡片撑高([来源滚动](2026-08-03-web-search-source-scroll.md))。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片看起来与它们属于同一类。整份 source 列表渲染在单个 `<ol>` 里,由 `max-height: 320px` 与 `overflow-y: auto` 约束,因此高于该值的列表在原地纵向滚动,而不是把卡片撑高([来源滚动](2026-08-03-web-search-source-scroll.md))。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
卡片在 chat 行中**常驻**于摘要行之下,与 `BashRow` 所用的同一常驻姿态。两个渲染点展示同一份完整的 source 列表,仅由卡片自身的滚动高度约束,而没有行与面板两级的 source 上限。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板渲染该卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
## Consequences
`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到约定的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
## Alternatives considered
**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而约定已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
**重解析模型可见的渲染文本,而非消费结构化视图。** 因约定笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
@@ -40,11 +40,11 @@ Status: implemented
`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。
fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配约定的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:当前计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
## Related
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费方。
- [Web search 来源卡片改为滚动而非折叠](2026-08-03-web-search-source-scroll.md) —— 用定高滚动容器替换本笔记的 source 列表头/尾折叠,并移除 `CHAT_WEB_MAX_SOURCES` 与原语自身的 source 上限;本笔记的其余决策依然成立。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费方。

View File

@@ -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/feature/2026-07-30-web-result-card.md
2026-07-30-web-result-card.md: 838b13a7f3240c753e5cd1af6909389055c6352d
2026-07-30-web-result-card.zh.md: 6e5170fcad82d709b050fb05e8efcfe955f20391
2026-07-30-web-result-card.zh.md: 9b0ad72aa6f152c8f7f4594dbe80532bf9722016

View File

@@ -1,4 +1,4 @@
# Agent Note: Web result card — a structured render intent for web_search and web_fetch
# Agent Note: Web 结果卡片——用于 web_search 和 web_fetch 的结构化渲染意图
Status: implemented
@@ -6,15 +6,15 @@ Status: implemented
## Problem
`web_search` 与 `web_fetch` 工具各自声明了一个 generic 待定卡片(`presentCall`,`kind: 'search'`/`'fetch'`),但没有 `presentResult`,因此一个已完成的 web 调用抵达 UI 时只剩下面向模型的 render 文本。对于想渲染引用列表或抓取摘要的 web 前端而言,该文本是有损的:`web_search` 的 render 把每个来源的 `title`、`snippet`、`publishedAt` 压进一行以 title 或 hostname 标注的自由文本 markdown(`packages/web/tool-web/src/search.ts` 中的 `formatSearchOutput`),因此重新解析 render 无法恢复各来源字段;`web_fetch` 的 render 也仅在一行 header 里携带 `url` 与 `statusCode`。渲染意图契约([标签联合类型](../architecture/2026-07-02-tool-render-intent-union.md))此前没有一个可供 web 工具声明、用以携带结构化结果的分支。
`web_search` 与 `web_fetch` 工具各自声明了一个 generic 等待中卡片(`presentCall`,`kind: 'search'`/`'fetch'`),但没有 `presentResult`,因此一个已完成的 web 调用抵达 UI 时只剩下面向模型的 render 文本。对于想渲染引用列表或抓取摘要的 web 前端而言,该文本是有损的:`web_search` 的 render 把每个来源的 `title`、`snippet`、`publishedAt` 压进一行以 title 或 hostname 标注的自由文本 markdown(`packages/web/tool-web/src/search.ts` 中的 `formatSearchOutput`),因此重新解析 render 无法恢复各来源字段;`web_fetch` 的 render 也仅在一行 header 里携带 `url` 与 `statusCode`。渲染意图约定([标签联合类型](../architecture/2026-07-02-tool-render-intent-union.md))此前没有一个可供 web 工具声明、用以携带结构化结果的分支。
## Decision
向 `ToolResultView`(`packages/core/tools/src/presentation.ts`)新增一个 `card: 'web'` 结果分支,它是以 `kind: 'search' | 'fetch'` 字段作判别的联合 `WebResultView = WebSearchResultView | WebFetchResultView`,并附一个表示单个可引用来源的 `WebSource` 形状。两个工具现在都声明 `presentResult`。
采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费者的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费者为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。
采用一个标签加 `kind` 判别,而非两个标签。两个调用都是 web 检索,web 前端会用同一族组件渲染它们(一个检索卡片,正文按 kind 不同),因此共用一个 `card` 让每个 card 消费方的 switch 只需新增一个分支,并让前端在其内部按 `kind` 分岔。两个标签会迫使当前及未来每个消费方为本属同一视觉族的东西添加两个分支。这两个 `kind` 取值与两个工具既有的 generic 调用视图 `kind` 一致,因此一个调用与它的结果读起来是同一类别。
`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费者无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——provider cap、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注分叉。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。
`presentationMeta` 携带 render 文本无法携带的东西。工具从 `execute` 返回的结构化结果对象**不会**经由 wire 抵达客户端——只有面向模型的 `render` 文本,以及(声明时)投影到 `tool/result` 事件 `meta` 上的 `output.presentationMeta` JSON 会抵达客户端。对 `web_search`,meta 是得到 `{url, title?, snippet?, publishedAt?}` 的**唯一**忠实途径:render 把这些字段压进一行有损的自由文本,消费方无法重新解析。对 `web_fetch`,meta 是更小但真实的收益:`url`/`statusCode` 可从确定格式的 `Fetched <url> (HTTP <n>)` header 行还原,但 `truncated` 是有效截断——提供方上限、转换前源截断,或部署的 `fetchMaxOutputChars` 输出上限——客户端无法重算,因为它不知道那个上限。抓取卡片与面向模型的文本都从同一个 `renderFetchOutput(result, maxOutputChars)` helper 派生 `truncated`,因此卡片绝不会与模型看到的脚注不一致。这照搬 write/edit 的 diff 模板(`packages/fs/tool-fs/src/diff.ts`):一个 `*MetaFromValue` 投影器喂给 `output.presentationMeta`,一个 `*MetaFromResult` 收窄器读回 `result.meta`,并在失败时防御性回退到 generic 卡片。`web_fetch` 的正文已是结果内容中的 markdown,因此不重复写入 meta。
两个结果视图都不携带 `content` 副本。不渲染结构化 `web` 卡片的 UI 回退到原始 `tool/result` 内容,这也是 generic 卡片消费的输入。把结果内容复制进视图会在同一投递帧上重复最多 `fetchMaxOutputChars` 个字符却毫无收益(与 meta 一节对抓取正文的否决同理),因此视图省略它,回退路径渲染完全相同的文本。每个视图从调用参数设置其结果期 `title`(`args.query`/`args.url`),因此丢掉了调用头的窗口截断重放仍有标题,与 write/edit 在结果期重设 title 的做法一致。
@@ -22,13 +22,13 @@ Status: implemented
## Consequences
前端消费方由后续独立 PR 交付:本次生产者变更新增契约分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `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。
前端消费方由后续独立 PR 交付:本次生产者变更新增约定分支并让两个工具发出它,不含客户端渲染。其唯一可观察的变化是 `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 工具,声明一个返回带自有 `kind` 的 `card: 'web'` 视图的 `presentResult`;新增第三个 `kind` 是一次联合类型编辑加前端的分岔,而非一个新的 card 标签。
## Alternatives considered
**两个 card 标签(`web-search`、`web-fetch`)。** 否决:它在每个 card 消费者处为一个视觉族翻倍分支数,而两个形状已共享得够多(一个带回退内容的带标题检索卡片),`kind` 判别无需第二个标签即可表达差异。
**两个 card 标签(`web-search`、`web-fetch`)。** 否决:它在每个 card 消费方处为一个视觉族翻倍分支数,而两个形状已共享得够多(一个带回退内容的带标题检索卡片),`kind` 判别无需第二个标签即可表达差异。
**在 `presentResult` 里重新解析 render 文本,而非投影 meta。** 对 `web_search` 否决:render 的来源列表是有损的(title 或 hostname 标签,snippet 与日期拼进自由文本),因此重新解析无法忠实恢复结构化字段。`presentationMeta` 是唯一保留它们的途径。
@@ -36,9 +36,9 @@ Status: implemented
## Testing
`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含对缺席可选字段的省略,以及抓取 `truncated` 投影在仅输出上限截断正文时、以及在毫无截断时都与 render 脚注一致;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含从参数派生的 title、无 `content` 副本、truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。
`packages/web/tool-web/tests/tool-web.spec.ts` 覆盖以下内容,满足按文件 100% 的门禁:`searchMetaFromValue`/`fetchMetaFromValue` 投影,含省略不存在的可选字段,以及抓取 `truncated` 投影在仅输出上限截断正文时以及在毫无截断时都与 render 脚注一致;`searchMetaFromResult`/`fetchMetaFromResult` 收窄,含一次往返与每种畸形形状的拒绝(非对象、字段类型错误、畸形来源条目)以及空来源列表的接受;`presentSearchResult`/`presentFetchResult` 类型化视图,含从参数派生的 title、无 `content` 副本、truncated 信号、错误结果回退与畸形 meta 回退;以及两次真实注册表执行,断言工具把 meta 投影到 `result.meta` 上,其注册的 `presentResult` 推导出 `card: 'web'` 视图。
## Related
- [标签化的工具调用渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md) —— 本卡片以 `web` 分支扩展的 `card` 标签词汇表。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费者是它的对应物,推迟到后续 PR。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 把 bash `terminal` 渲染意图带到浏览器的先例;本分支的 web 前端消费方是它的对应实现,推迟到后续 PR。

View File

@@ -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/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
2026-07-30-web-tool-row-unified-expand-and-inspect.md: 4fcf1e9a567b73cd04f1bbed170c391aaedd9e73
2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 92d70f5aa6bd09e6f9e54015dafc38d8d3690a9e
2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 765cd058c99e53e5dea81f83c203a6d75d56f104

View File

@@ -1,29 +1,29 @@
# Agent Note:Web 工具行统一展开交互与 trajectory Inspect
# Agent Note: Web 工具行统一展开交互与 trajectory Inspect
状态:已实现
Status: implemented
[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
## 问题
聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
## 决定
**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留语义阅读位置。**
- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,作为折叠摘要并以错误色显示)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
- Inspect:`ToolCallOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。
- Inspect:`ToolCallOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角的正常布局流中渲染胶囊,hover 到工具调用的任意位置时显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
- 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并以 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。
## 曾考虑的替代方案
**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互约定本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接具备回放安全性(字段出现之前的持久化快照以 `?? null` 复水)。
**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。

View File

@@ -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/feature/2026-07-31-browser-derived-initial-locale.md
2026-07-31-browser-derived-initial-locale.md: 0c49a6bbfec0ab33a5aa3ce53dde0cac747f3816
2026-07-31-browser-derived-initial-locale.zh.md: c013d24dcd3bb49d176eaddd42ff41dde320ff1f
2026-07-31-browser-derived-initial-locale.zh.md: ca8eeceae58e25836bb42f7c1cec0e61a232d921

View File

@@ -16,21 +16,21 @@ Status: implemented
**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。
**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对"用户是否做过选择"这一问题始终不可见。
**显式选择是永久的。** `setLocale` 的持久化未作改动,且持久化值最先被查询,因此选过语言的用户即便在不同浏览器配置文件或系统语言之间辗转也保留原选择。没有任何代码把探测到的 locale 写回存储:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。
**浏览器 e2e 车道现在钉住浏览器语言,而不只是存储项。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 仍然钉 `dsh.locale=en`,它依旧压过任何浏览器语言。`settings-chrome.e2e.ts` 新增一个场景:另开一个存储项为空的 `en-US` 页面,断言设置界面以英文呈现——这是本功能在组装后应用中的证据。
## Alternatives considered
- **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。
- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着"用户选了它"。
- **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。
- **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。
- **为默认 locale 增加一个 cordis config key**:此处部署之间并无差异——回落值是产品对"完全没有信号"给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费者、且随部署变化的选择。
- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。
- **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。
## Consequences
- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。
- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当"用户尚未选择"。
- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。
- 在 jsdom 下构造 `LocaleService` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。
- 探测的代价是每次服务构造遍历一次数组,且不写存储,因此启动行为与持久化状态面均无变化。

View File

@@ -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/feature/2026-07-31-code-mode-language-dispatch.md
2026-07-31-code-mode-language-dispatch.md: 71e4fd5a30e90ac66132dbdcea16917bacc80b68
2026-07-31-code-mode-language-dispatch.zh.md: 1dbb3d889d28669575980d3a667e4f168a5d72db
2026-07-31-code-mode-language-dispatch.zh.md: 3cbd587b52eb650b071fc072acd73780435dc91a

View File

@@ -23,24 +23,24 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd
### Python SDK 渲染器
`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有三条规则并非源自排序,而是 Python 特有。其一,用法约定声明这些声明只是静态存根、参数为普通 `dict`/`list` 值:`TypedDict` 读起来像一个可构造的类,模型若写 `FooArgs(field=1)` 会得到 `NameError`——TypeScript 的 `interface` 一眼就是类型,且 TS 形态的「runs type-stripped」一句已经覆盖了它。其二,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其三,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。
`py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON Schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的约定一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。字典序意味着单一有序的成员流:名字不是合法属性的工具以 `tools[name]` 注释出现在它排序后的位置上,而不是被分拣到末尾,与 TypeScript 形态就地为异常键加引号的做法一致。这个成员流直接决定了一件事:注释行不是语句,所以一个不发出任何方法的工具集仍需显式 `pass`。另有三条规则并非源自排序,而是 Python 特有。其一,用法约定声明这些声明只是静态存根、参数为普通 `dict`/`list` 值:`TypedDict` 读起来像一个可构造的类,模型若写 `FooArgs(field=1)` 会得到 `NameError`——TypeScript 的 `interface` 一眼就是类型,且 TS 形态的「runs type-stripped」一句已经覆盖了它。其二,描述会成为方法的 docstring,且必须作为方法体的**第一条语句**发出:放在 `async def` 之上,第一条会变成 `Tools` 的类文档、其余都是无效果表达式,导致每个方法都没有文档。其三,`list[…]` 链超过 `MAX_LIST_NESTING` 后降级为 `Any`,因为 CPython 的 tokenizer 拒绝一行中超过 200 个同时未闭合的括号,而这个块必须是可解析的 Python——与 `docLines` 转义引号和反斜杠是同一个理由。`ts-types` 两者都不需要:TypeScript 会把前置的 `/** … */` 附着到其后的成员上,其语法也不对嵌套设限。
该上限服务的标准是**语法合法性**,这条边界是有意划定的:长的 `A | B | …` union 在任何长度下都是合法 Python,故不设上限——尽管 CPython 的 `compile()` 在沿左嵌套 `BinOp` 脊柱下降时会耗尽 C 递归(在 3.9 上实测:1,000 个分支可编译,5,000 个抛 `RecursionError`)。没有任何东西会编译这个块——它是提示词文本——所以那条限制在这里没有代价;而给 union 长度封顶会作废那几个钉住 walk 线性时间与类名传播上限的深链测试。将来若有渲染器确实需要可编译的输出,应当把 union 拍平,而不是截断。
`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。
`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的「校验后信任」姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对「访问器在多次读取间变值」的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是第一方注册(`defineTool` 字面量或 raw 注册)或从 wire 桥接而来的纯 JSON——前者按 AGENTS.md 受信任,后者是 `JSON.parse` 产物、物理上不可能携带访问器,且每次调用 `renderType` 都会整树重新校验——这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的约定是「降级不支持的 schema」,而非「扛住对抗性的可变 schema」。
## Alternatives considered
## 考虑过的替代方案
- **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。
- **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是真源,故注册表读取它而不复制它。
- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR 先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。
- **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。
## Consequences
## 后果
新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员、一个 `SDK_RENDERERS` 表项、一个 `RUN_CODE_FLAVORS` 表项——再加第二处所指向的渲染器函数,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步,且这条不变式由静态检查把关,而非交给 review:两张表都以 `satisfies` 对上述同一个 union 校验,因此只加其一而漏掉另一会在 `typecheck` 处失败。这正是该漂移风险应有的机械形式——运行期的 `Object.hasOwn` 守卫同样能捕获,但要等到有后端报告该语言之后:晚于漂移引入一个 PR,且触发点在消费方的集成处而非漂移引入处;在当前 base 上则永远不会触发,因为不存在第二个后端。两张表的声明类型仍是 `Record<string, …>`,因为 `CodeRuntime.language` 是不受约束的 `string`:union 钉住本仓库交付了什么,守卫拒绝运行时报告了什么。落在这条检查之外的是点名已知值而非从中派生的散文:seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/subsystems/code-runtime.md` 双语对,再加本包自己的 README 双语对与它的 `Config.mode` JSDoc。更早的 note 点名这些值时记的是其自身 PR 当时的状态,不在此列。让它无 gate 的是两条独立理由。其一,散文根本不受类型检查,union 放在哪里都一样。其二,类型级替代在这里也不可用:接口包不得 import 其消费方的表,而 `CodeRuntime.language` 按设计保持不受约束的 `string`,即便把 union 迁进 seam 也不会作用到它。用一个断言两张表键集相等的 unit test 的方案被否决:它买到的是同一条检查,代价却是把两张私有表做测试专用导出,且运行时机晚于编译器。对两张表都缺席的语言,两种运行期失败中报出哪一条随入口而异:组装路径报缺渲染器,因为 `wireSchemas` 在投影前先调 `requireCodeRuntime`;而公共 `schemas()` 先经过 `run_code` 的语言感知 getter,报的是缺 flavor 表项。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测。
代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture 运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript。
代价是两张表的 Python 分支在当前 base 上不可达:`CodeRuntime.language` 由所加载的后端设定,已发布的后端只有 `dsh-code-runtime-worker`(`'typescript'`),而注册表读取的是所加载的运行时而非某个配置字段,因此没有任何一份组装好的应用能选中 `renderToolsSdkPy` 或 `PYTHON_FLAVOR`。也就是说,在报告 `'python'` 的后端发布之前,本 note 的工作不改变模型可见表面,本 PR 的覆盖因此是 unit 级——渲染器输出加分发与拒绝路径。Python 模型界面的 keyless snapshot 归属于发布该后端的那个 PR,因为只有在那里,一份基于已发布插件的真实 `cordis.yml` 才会产出 Python 组装;在此处挂载 fixture(测试前置数据)运行时的快照示例断言的是测试替身,而 [docs/testing.md](../../../../docs/testing.md) 明确拒绝以此替代组装好的应用 transcript(文本记录)。
Python SDK 文本断言的两条运行时契约同样归属那个 backend PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 契约填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。
Python SDK 文本断言的两条运行时约定同样归属那个后端 PR。其一,说明文字告诉模型运行时恰好绑定 `tools` 与 `ToolCallError` 两个名字、所声明的 `TypedDict` 类不绑定,因此后端必须注入这两个名字(并按 seam 的 `errorClass` 约定填充 `ToolCallError.toolName`),且**不得**把所声明的类名绑进程序全局——「好心」注入会使这段 SDK 文本变成假话。其二,语言必须绑定到请求上:`requireCodeRuntime` 在组装时与 `run_code` 执行时分别解析 `ctx.codeRuntime`,若在这两点之间发生重载并换掉运行时,就会把针对一种形态写成的程序交给另一种形态执行。分裂比这两点更细——`run_code` 的 `description` 与 `parameters` 两个 getter 各自调用 `resolveFlavor(peekRuntime())`,而 `schemaOf` 会解构这两个字段,因此一次投影读两次运行时;两次都属于 `run_code` 自己的 schema,因为这两个 getter 只装在那一个 definition 上,其余 definition 携带的都是普通数据属性。在这两次读取之间重载会产出单个 schema 的两半分属不同语言。两者在此处都不可达——只有一个已发布后端意味着两次读取返回同一形态,且没有任何程序会针对本渲染器的输出运行——而跨语言拒绝在第二门语言存在之前也无法测试。
其三,那个 PR 拥有 CPython 版本下限,连带拥有本渲染器的 Unicode 表偏斜。有四处表达式读所运行引擎的表(Node 22.23.1:Unicode 17.0),而解释器用它自己的表(CPython 3.9.6:13.0.0):`isBareIdentifier` 的 `IDENTIFIER`,以及 `camelCase` 的切分集、头部测试与 `toUpperCase()`。解释器旧于引擎是会失败的那个方向——引擎发出的字符被其 tokenizer 拒收,整个块随之不可解析——而它经三条独立路径抵达。经判据抵达的是裸发的方法名或字段名,其中带有一个在两个版本之间新增的字符——首位加进 `XID_Start`,或尾部任意位置(含名字中部)加进 `XID_Continue`。经 `camelCase` 的 XID 读取抵达的是类名:只要工具 schema 中有任一对象形态声明 `TypedDict`,该类名就进入发出的文本,且判据对工具名的裁决并不对它设闸——工具名 `zz-` 加 U+1E4D0 因 `-` 被判据直接拒绝、从不触及那里的偏斜,却照样声明 `class Zz𞓐xArgs`。经大写映射抵达的是由判据已接受的工具派生出的类名——这是另一张表,窗口也比 XID 归属更宽:U+019B 既是 XID_Start 又 NFKC 稳定,故 `async def ƛ` 在 3.9.6 上可编译,但 Node 将其大写为 U+A7DC(在那里未分配;CPython 自己的 `.upper()` 在此是恒等),于是 `class ꟜArgs` 以 `invalid non-printable character U+A7DC` 失败。暴露窗口是两个版本之间发生变化的那些字符与映射,所以宣布支持某个 CPython 范围的那个 PR 必须在「接受该暴露」与「按该下限的表钉住全部四个读取点」之间显式作出决定——只钉判据会同时留下两条类名路径。此处无法决定:下限尚不存在,而按猜测钉死一张表会成为一个随部署而变、却没有可配置性支撑的常量。还有第二条轴随该下限一同确定,且不属于那四个读取点:本块在定义期会被求值的那些名字与语法。`TypedDict` 需要 3.8,PEP 585 的内建泛型 `dict[str, Any]` 与 `list[…]` 需要 3.9,`A | B` 形式的注解需要 3.10,`NotRequired` 需要 3.11。这些不是解析失败——本块在任何版本上都能解析,这正是 `MAX_LIST_NESTING` 上限所服务的标准——而是定义期求值失败,且产品中没有任何东西会求值这段文本。把它们与那四个读取点记在一起,可避免把「在所支持范围上可解析」读成「在其上可执行」。

View File

@@ -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/feature/2026-07-31-even-out-shipped-tool-rosters.md
2026-07-31-even-out-shipped-tool-rosters.md: 0195620055da5e570d2f54792d950a88bab8d652
2026-07-31-even-out-shipped-tool-rosters.zh.md: cc67f24720f650283daa4ee85a0a1af5358756a8
2026-07-31-even-out-shipped-tool-rosters.zh.md: ab6982e33c4a0a25cbc2fde386456840ce99d9c5

View File

@@ -6,19 +6,19 @@ Status: implemented
## 问题
两个交付的 `dsh` surface 提供着不同的工具,而没有任何记录说明为什么。会话检查点、工具结果裁剪、goal 工具和 Ralph 在 `tui.cordis.yml`;`tool-todo` 以及后来的 web 搜索在 `web.cordis.yml`。两个 surface 都没有会话搜索、字符串替换编辑器和重复工具守卫,尽管这三者都已成包存在,且没有一个是 surface 专属的。
两个交付的 `dsh` surface 提供着不同的工具,而没有任何记录说明为什么。会话检查点、工具结果裁剪、goal 工具和 Ralph 在 `tui.cordis.yml`;`tool-todo` 以及后来的 web 搜索在 `web.cordis.yml`。两个 surface 都没有会话搜索、字符串替换编辑器和重复工具守卫,尽管这三者都已成包存在,且没有一个是 surface 专属的。
结果是一处没人做过决定的用户可见差异:同一个模型、同一个请求,在终端上能定目标而在浏览器里不能,在浏览器里能搜网页而在终端上不能。
结果是一处没人做过决定的用户可见差异:同一个模型、同一个请求,在终端上能定目标而在浏览器里不能,在浏览器里能搜网页而在终端上不能。
## 决策
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。
那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。
### 什么保持不挂,以及为什么
### 什么保持不挂,以及为什么
有三项能力基于其自身包所记录的证据保持在外,列在这里是为了让「我们忘了」和「我们决定不要」保持可区分。
@@ -26,13 +26,13 @@ Status: implemented
**`dsh-web-fetch-local`** 保持不挂,`dsh-tool-web` 保持 `fetch: false`。SSRF 防护在实现中是 deferred 状态([`policy.ts`](../../../../packages/web/web-fetch-local/src/policy.ts) 只校验协议、凭据与长度),包里也直说了:「this provider is an SSRF primitive and **must not be enabled** in a deployment that can reach sensitive internal network targets」([README](../../../../packages/web/web-fetch-local/README.md))。目标由模型选择,其中包括 harness 自己跑在环回地址上的网关、内网段和云元数据端点。
弃用它收窄的是接触面而非可达性:`bash` 是挂着的,`curl` 照样能拿到同一个页面——一次真实运行确认了这点。这个缺席买到的是去掉一个无需 shell、以参数成形的请求原语,以及随之而来的那条意外路径:一次「帮我总结这个页面」悄悄打到环回地址。真要收住出站流量的部署需要的是网络层管控。
不挂载它收窄的是接触面而非可达性:`bash` 是挂着的,`curl` 照样能拿到同一个页面——一次真实运行确认了这点。这个缺席买到的是去掉一个无需 shell、以参数成形的请求原语,以及随之而来的那条意外路径:一次「帮我总结这个页面」悄悄打到环回地址。真要收住出站流量的部署需要的是网络层管控。
**LSP 三件套**留在外面是运维原因而非安全原因:`command` 在插件加载时从 `PATH` 解析,因此缺少语言服务器会让整次启动失败,而不只是失去一个工具。等到「缺失」退化为「跳过注册」之后,它就可以挂了。
### MCP 是依赖,不是配置行
`@deepseek-ai/dsh-mcp-client` 成为本 CLI 的运行时依赖,但在任何交付配置里都没有对应的行。该插件每个实例只挂载一台服务器,且 `command` 是必填,因此一个默认值必须点名一台第三方服务器,并在每次启动时把它作为子进程 spawn——不经 `ctx.bash`,因而也在 Web surface 所组合的沙箱策略之外。
`@deepseek-ai/dsh-mcp-client` 成为本 CLI(命令行界面)的运行时依赖,但在任何交付配置里都没有对应的行。该插件每个实例只挂载一台服务器,且 `command` 是必填,因此一个默认值必须点名一台第三方服务器,并在每次启动时把它作为子进程 spawn——不经 `ctx.bash`,因而也在 Web surface 所组合的沙箱策略之外。
真正能让 MCP 成为默认的那一层,恰恰是本仓库尚未拥有的:一个读取用户服务器清单、按条目逐台挂载客户端的桥接,形态与 [`dsh-hooks-claude`](../../../../packages/hooks/hooks-claude/README.md) 读取 Claude Code 的 `hooks.json` 完全相同。交付这个依赖意味着已安装的 `dsh` 今天就能从 `$DSH_HOME/config.yaml` 挂载服务器;CLI README 里给了那段 YAML。
@@ -54,11 +54,11 @@ Status: implemented
**把共享的行复制进两份 overlay,而不是提升到 base。** 基于「一处归属」原则否决:新增行里有三行会存在两份,而这些副本没有任何理由发生分歧,下一次改工具清单还得记着改两处。
**在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升级请求在那里会 fail-closed,而不是弹出提示。
**在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升权请求在那里会 fail-closed,而不是弹出提示。
**开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。
**默认挂一台 MCP 服务器。** 否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。
**默认挂一台 MCP 服务器。**否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。
## 后果

View File

@@ -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/feature/2026-07-31-gui-full-access-confirmation.md
2026-07-31-gui-full-access-confirmation.md: ca89ed23fb1b5c6ea438dd22fdf20d2b82af754c
2026-07-31-gui-full-access-confirmation.zh.md: 0f487e41b544718bdf38de611a1ab2d59c44b063
2026-07-31-gui-full-access-confirmation.zh.md: a31c1ee744b111dd6917f0d2ed7ba9988c2aee47

View File

@@ -4,28 +4,28 @@ Status: implemented
[English](2026-07-31-gui-full-access-confirmation.md) | 中文
## Problem
## 问题
在 Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
在 Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许 agent(智能体)执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
## Decision
## 决策
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot以 `access.confirm.*` 键供给。
- `/permission` popup(ui-permission 构建于 ui-command 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
## Alternatives considered
## 考虑过的替代方案
**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
**每个面的安全文案共享一个 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案,ui-permission 也将 popup 与 Settings 词典分开,而非跨 bundle 边界 import。
**每个界面的安全文案共享一个 locale namespace。**不予采用:ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案,ui-permission 也将 popup 与 Settings 词典分开,而非跨 bundle 边界 import。
**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
## Consequences
## 后果
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器通过各自拥有的状态机复用共享对话框,或在 popup 路径挂 `confirmation` 载荷。验收:`input-bar.spec.tsx` 中编辑器流的门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`permission-row.spec.tsx` 的默认设置门控、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 Web 回放。
进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器通过各自拥有的状态机复用共享对话框,或在 popup 路径挂 `confirmation` 载荷。验收:`input-bar.spec.tsx` 中编辑器流的门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`permission-row.spec.tsx` 的默认设置门控、`atoms.spec.tsx` 的 Modal/RiskConfirmation 约定,以及组装态 Web 回放。

View File

@@ -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/feature/2026-07-31-permission-default-for-new-sessions.md
2026-07-31-permission-default-for-new-sessions.md: ffa4c8a07bdd08ca52edbc14fe10372ad76e8cf8
2026-07-31-permission-default-for-new-sessions.zh.md: 5fc42724754acc1653ed93b48644794a38f52ba7
2026-07-31-permission-default-for-new-sessions.zh.md: c70d431ec9bbe8cdeabfdc8a0052a9c436844f02

View File

@@ -6,7 +6,7 @@ Status: implemented
## 问题
Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM 提供方的 namespace。更重要的是,如果把用户偏好当成实时生效的全局权限,现有会话的执行策略就会在其持久日志之外发生变化。
Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM(大语言模型)提供方的 namespace。更重要的是,如果把用户偏好当成实时生效的全局权限,现有会话的执行策略就会在其持久日志之外发生变化。
## 决策
@@ -26,10 +26,10 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入
## 曾考虑的替代方案
**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,重放也无法重建先前工具调用采用了哪种权限。
**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,回放也无法重建先前工具调用采用了哪种权限。
**创建时只记录 `permission/preset`。** 不予采纳,因为沙箱和审批是由不同组件独立拥有的全量值调节项;固定全部三个事实,可以让其消费方不依赖未来的组合默认值变化。
**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 契约超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。
**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 约定超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。
**恢复带 seed 的会话时应用最新默认值。** 不予采纳,因为恢复操作必须保留会话先前的有效执行策略;缺失的旧版事实应从该策略中补齐。

View File

@@ -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/feature/2026-07-31-session-archive-global-set.md
2026-07-31-session-archive-global-set.md: fab99a405a6f8264c36453473327e32905bac9c8
2026-07-31-session-archive-global-set.zh.md: e33f3b5272a6d8fc90cfad247ba21d4d10afb045
2026-07-31-session-archive-global-set.zh.md: b10440039f1c6a8ea2f89d9239a0285dd70cf9a2

View File

@@ -1,26 +1,26 @@
# Agent Note: Session 归档(注册表级全局集合)
# Agent Note: 会话归档(注册表级全局集合)
状态:implemented
Status: implemented
[English](2026-07-31-session-archive-global-set.md) | 中文
## 问题
Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为**归档**而非删除:session 日志与 workspace 记账都不动,只把该 session 从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的 session 不属于任何 workspace 实体,per-workspace 字段放不下它。
Sidebar workspace 浏览区的会话行菜单里,「Delete session」一直是纯视觉占位(无 handler)。产品口径定为**归档**而非删除:会话日志与 workspace 记账都不动,只把该会话从所有分组视图(workspace 分组、Ungrouped、搜索、平铺列表)里隐藏。归档记录需要一个落点:Ungrouped 的会话不属于任何 workspace 实体,per-workspace 字段放不下它。
## 决策
**归档集合是 workspace domain 全局单例(`workspaceDomainState.archivedSessionIds`)上的一个新字段,覆盖在 workspace 记账之上;显示过滤全部收敛在 client 的 `tree.ts` 派生层;wire 面走全快照姿态。**
- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯增量字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的 session 保留其 `sessionIds` 席位(未来取消归档恢复原位置),因此与「一个 session 只被一个 workspace 记账」不变式零纠缠。
- Registry:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知 session(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。
- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答完整更新后集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知 session 复用错误码 `session-not-found`。
- client runtime:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都整体替换安装。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的 session 主视图回 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线恢复出一个离线期间被归档的 selection;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。
- 存储:`archivedSessionIds: z.array(sessionId).default([])`,domain version 保持 2——纯新增字段,旧介质经 schema default 解析为空集合,无迁移代码。被归档的会话保留其 `sessionIds` slot(未来取消归档恢复原位置),因此与「一个会话只被一个 workspace 记账」不变式零纠缠。
- 注册表:`ctx.workspace.archiveSession(id)` 走 `enqueueOperation` 与 create/delete 串行;未知会话(实时与持久化都查不到)抛 `WorkspaceUnknownSessionError`;已归档 id 不写盘不发事件。`archivedSessionIds` getter 暴露只读集合。
- RPC:`workspace.archiveSession({sessionId}) → {archivedSessionIds}`(应答更新后的完整集合);`workspace.list` 响应携带集合作为重连基线;新 host 帧 `host/archived-sessions-changed` 在每次持久变更后推完整快照(与 `host/workspace-changed` 同姿态,从 `domain/changed` 的 global put 分支比对推帧)。未知会话复用错误码 `session-not-found`。
- client 运行时:`WorkspaceListState.archivedSessionIds`(按 Host 顺序的 `readonly SessionId[]`,成员不变不换引用——公有快照状态保持 store 引擎的纯数据词汇:immer draft 不开 MapSet 插件就不接受 Set;membership 查询在派生函数内自建临时 Set,与 expandedProjects 同款);list 基线、unary 回声、changed 帧三路都会用完整集合整体替换现有值。投影层在当前 selection 落入归档集合时统一清空回 New Session 视图(用户拍板:归档当前打开的会话会使主视图回到 hero)——一条规则同时覆盖本地 unary 回声、其他标签页的 changed 帧、以及重连基线发现当前 selection 已在此 client 离线期间被归档的情形;帧/回声落在 in-flight `workspace.list` 期间时还会屏蔽旧基线对新集合的回滚。
- UI:菜单项 `delete`(visual-only)改为 `archive`(label「Archive session」,非 danger 样式,无确认对话框——非破坏性操作,误触后果只是列表隐藏);过滤实现为 `tree.ts` 的 `sessionVisible` 判据加一档,`deriveGroups`/`deriveFlat` 增加 `archived` 集合入参,四个视图(分组循环、stray 兜底、搜索、平铺)同源生效。
## 已考虑的替代方案
**per-workspace archivedSessionIds(最初表述)。** 否决:Ungrouped session 无落点;用户改口全局。
**per-workspace archivedSessionIds(最初表述)。** 否决:Ungrouped 会话无落点;用户改口全局。
**SessionSummary 打 archived 标(session.list 层)。** 否决:要把 workspace domain 事实 join 进 sessions domain 投影,summary 无增量帧还得另发通知,跨域耦合大于收益。
@@ -30,4 +30,4 @@ Sidebar workspace 浏览区的 session 行菜单里,「Delete session」一直
## 后果
归档后 UI 无查看/取消归档入口(本期口径,README Known Limitation 记账);数据与席位完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。
归档后 UI 无查看/取消归档入口(本期口径,记录在 README 的 Known Limitation 中);数据与slot完好,后续加恢复面只是 UI + 一个逆向 RPC。`workspace.list` 响应形状变化是 pre-release 直改(无兼容层)。e2e(workspace-management)钉住了「归档→行消失→reload 后仍隐藏、日志仍在」的全链路;domain 层测试钉住幂等、未知 id 拒绝、跨重启恢复与旧介质默认升级。

View File

@@ -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/feature/2026-07-31-telemetry-anonymous-user-id.md
2026-07-31-telemetry-anonymous-user-id.md: 3c8e3324cb418eac48cb5ae780c55bbcbaf3caa1
2026-07-31-telemetry-anonymous-user-id.zh.md: 9ff6cf35a90d4087b4ab75987dc9244210e3d46a
2026-07-31-telemetry-anonymous-user-id.zh.md: b8b90bbad64abc409d743f89fb156bb94d7f6a7f

View File

@@ -10,19 +10,19 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
## Decision
`session-telemetry-otel` 包内模块 `src/user-id.ts` 是 OTel 回流用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;backend 构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。该身份只属于 OTel 回流;dsh-sdk launcher telemetry 保留自己的匿名 id 存储(`telemetry.json`),两者不共享(初版曾做公用 util 包统一两条回流,用户复议后收回:在有第二个真实消费者之前不抽公共包,回流关联需求出现时再议)。
`session-telemetry-otel` 包内模块 `src/user-id.ts` 是 OTel 回流用户身份的属主:`getOrCreateAnonymousUserId()` 返回 `$DSH_HOME/.userid`(`resolveDshHome` 解析,`$DSH_HOME` > `~/.dsh`)中的裸 UUID 行,首用生成随机 UUID v4 并落盘;backend 构造时把它作为 Resource 的 `user.id`(OTel semconv 标准用户属性)随每批导出携带一次。该身份只属于 OTel 回流;dsh-sdk launcher telemetry 保留自己的匿名 id 存储(`telemetry.json`),两者不共享(初版曾做公用 util 包统一两条回流,用户复议后收回:在有第二个真实消费方之前不抽公共包,回流关联需求出现时再议)。
| 裁定 | 取值 | 理由 |
|---|---|---|
| id 来源 | 随机 UUID v4,绝不从 hostname/网络地址/git remote 派生 | 派生 id 可反查,「匿名」名不副实 |
| 存储形态 | `.userid` 裸 UUID 行 + 换行,无 JSON 包装 | 身份是独立事实,不挂在某条 telemetry 链路的文件命名/格式下 |
| 读写形态 | 同步 IO + 进程内按解析后文件路径 memo | `TelemetryOtel` 构造函数是同步的(async 迫使插件装载改形);一进程一次盘 IO,运行中删文件不影响本进程 |
| 并发首启 | `wx` 独占写裁决,落败方重读胜者 id | 覆盖常见并发(重读撞进胜者建档-写入微秒窗仍可能各持一 id 一次运行,下次启动收敛到落盘值——telemetry 级后果,接受) |
| 并发首启 | `wx` 独占写裁决,落败方重读胜者 id | 覆盖常见并发(若重读恰好落在胜者创建文件至写入内容之间的微秒窗口内,该次运行中仍可能每个进程各持一个 id;下次启动时会收敛到已落盘的值——这是 telemetry 级后果,可以接受) |
| 丢失语义 | 文件被删 → 下次启动换新 id,接受丢失 | 匿名身份无恢复价值;可恢复性要求派生材料,与匿名冲突 |
| 写失败 | best-effort 返回内存 id | telemetry 永不因 home 只读被阻塞 |
| 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 契约且涨 wire 体积 |
| 上报位置 | Resource 属性,非逐条 attributes | 每批一次即够接收端按 Resource 维度聚合;逐条注入要动 seam 约定且涨 wire 体积 |
| semconv 依赖 | 不引 `@opentelemetry/semantic-conventions` 包 | 一个字符串常量不值一个依赖 |
| 落点 | `session-telemetry-otel` 包内模块,非公共 util 包 | 仓规「有第二个真实消费者才拆包」;sdk launcher 回流保留自有存储,无现实关联需求 |
| 落点 | `session-telemetry-otel` 包内模块,非公共 util 包 | 仓规「有第二个真实消费方才拆包」;sdk launcher 回流保留自有存储,无现实关联需求 |
| 单独开关 | 无 | 身份跟随 telemetry 整体开关(`DSH_TELEMETRY_DISABLED`);关 telemetry 即整体不报 |
## Alternatives considered
@@ -30,10 +30,10 @@ session telemetry 已默认挂载([默认挂载 Note](2026-07-31-web-telemetry
| 被拒 | 一句话理由 |
|---|---|
| hostname/IP 哈希派生 id(此前口径) | 可反查即非匿名;随机 UUID 语义干净,用户裁决取代 |
| user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 契约或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 |
| 公用 util 包统一两条回流(初版实现) | 唯一现实消费者是 OTel backend;sdk launcher 换用它只是为统一而统一——用户复议收回,回流关联需求出现时再抽包 |
| user.id 放每条 record 的 attributes(Claude Code 形态) | 要动 session-telemetry seam 约定或逐条注入,wire 体积涨;Resource 每批一次已满足聚合 |
| 公用 util 包统一两条回流(初版实现) | 唯一现实消费方是 OTel backend;sdk launcher 换用它只是为统一而统一——用户复议收回,回流关联需求出现时再抽包 |
| 复用 telemetry.json 不新建文件 | 文件名/JSON 格式把身份挂在 launcher 链路命名下;OTel 回流身份是独立事实 |
| AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;config 里传运行时事实与部署配置混淆 |
| AppCLIEntry 读好 id 经 config patch 注入 | 每个 surface 入口都要接线;在 config 里传运行时事实,会混淆运行时事实与部署配置 |
| 挂进 `@deepseek-ai/dsh-paths` | paths 是纯路径计算零 IO;带持久化的身份能力会污染包边界 |
## Consequences

View File

@@ -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/feature/2026-07-31-web-default-search.md
2026-07-31-web-default-search.md: 8f2cc6f5d1a9ec9f33279ae8e5c992bdc660e7fa
2026-07-31-web-default-search.zh.md: 7d528617143cb9756d0a4532bb1ed6a3a3e70ca8
2026-07-31-web-default-search.zh.md: 46db167b84f8c1a10686a941b174008ebafc720d

View File

@@ -16,7 +16,7 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash/文件系统沙箱及审批预设之外执行,并遵循 `dsh-tool-web` 的现有约定。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
## 考虑过的替代方案

View File

@@ -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/feature/2026-07-31-web-telemetry-default-mount.md
2026-07-31-web-telemetry-default-mount.md: c1525a44196991d5059969ea70af34501b199323
2026-07-31-web-telemetry-default-mount.zh.md: f203ea1943387beda445bd81ac78fa2cc0471d45
2026-07-31-web-telemetry-default-mount.zh.md: ef5f7e4500a1776e049ac27e0e64756e78725a61

View File

@@ -4,11 +4,11 @@ Status: implemented
[English](2026-07-31-web-telemetry-default-mount.md) | 中文
## Problem
## 问题
遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。
遥测 seam 与 OTel 后端([revival Note](2026-07-23-session-telemetry-otel-revival.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话的可观测性为零。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。
## Decision
## 决策
`dsh` 共享 base(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此 Web 与 headless 都会上报;原始配置命令也会先挂载该行,再应用其必需的部署 overlay。这是**内部测试期的部署立场**——有 endpoint 就上报,用户可通过环境变量退出。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md),在启动器 5 秒上限到期前,先给后端 3 秒关闭截止时间完成排空。
@@ -17,23 +17,23 @@ Status: implemented
| 挂载面 | base.cordis.yml(原始配置 + Web + headless) | 所有加载共享 base 的配置树采用同一个部署立场;原始配置 overlay 决定该部署是否创建会话 |
| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢失最后一个尚未导出间隔内的数据 |
| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048`(与 maxQueueSize 相等) + `exportTimeoutMillis: 1500` + `shutdownTimeoutMillis: 3000` | collector 不可达的常规故障会在约 1s 内放行:timeoutMillis 是单次 socket 超时与重试 deadline,使用与队列等大的单批可避免依次排空导致耗时倍增。由 DSH 管理的 3s 外层上限覆盖 SDK 先执行的无界 `forceFlush()` 等待,即传输 Promise 始终无法取得 socket 的情况。 |
| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
| CI 隔离 | 全部 8 个 GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/快照/已构建版本的冒烟测试)不得向生产 endpoint 泄测试会话 |
集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、chunk 首条投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。
集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM(大语言模型)服务器,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、步骤首个分片投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。
## Alternatives considered
## 考虑过的替代方案
**默认不挂载,部署方自行加行(SDK 立场的延续)。** 否决于当前阶段:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 seam 包保持(不挂 = 零外发)。
**默认不挂载,部署方自行加行(SDK 立场的延续)。** 当前阶段不采用:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 seam 包保持(不挂 = 零外发)。
**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
**退出时 `Promise.race` 兜底超时。** 最初暂缓,是因为 SDK 参数看似已经将后端排空耗时限制在约 1.5-3s(通常 <100ms),实测 SIGINT 到退出耗时 110ms-1.1s。后来在 Linux 沙箱中复现并证明,`BatchLogRecordProcessor.shutdown()` 可能在 `exporter.forceFlush()` 中永久等待,无法进入受 `exportTimeoutMillis` 限制的完成 Promise。因此,[CLI 关闭修复](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) 既为这一特定缺口增加 3 秒后端上限,也为整棵插件树增加 5 秒进程级上限和重复信号退出途径。
## Consequences
## 后果
- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。
- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、其余身份 Resource 维度(hostname/surface;匿名 user id 已由[匿名用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)落地)、使用数据 metrics 轨是本决策明确的后续工作。
- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。
- **当前尚未挂载任何脱敏规则**:导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、其余身份 Resource 属性(hostname/surface;匿名 user id 已由[匿名用户 id Note](2026-07-31-telemetry-anonymous-user-id.md)落地)、使用数据 metrics 轨是本决策明确的后续工作。
- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture(测试前置数据)会话会流向 env 里碰巧存在的 collector。

View File

@@ -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/feature/2026-07-31-web-workspace-file-links.md
2026-07-31-web-workspace-file-links.md: fe71a2d8caa4dc9cc732a250d7e388d1a3b44182
2026-07-31-web-workspace-file-links.zh.md: 0f8c87822087169aad9ac4283f706fd8a8748be9
2026-07-31-web-workspace-file-links.zh.md: a59ca5c6ba3a2c4aee9129deedfbb486cec082b2

View File

@@ -1,30 +1,30 @@
# Agent Note:从 web UI 打开产出的文件
# Agent Note: 从 web UI 打开产出的文件
Status: implemented
[English](2026-07-31-web-workspace-file-links.md) | 中文
> 范围:完成的一轮以其产出文件收尾的那一行、读得出是链接的文件路径链接,以及 Host 打开器对浏览器可渲染文档优先选用默认浏览器。经决定不在范围内:以 HTTP 提供工作区文件,以及为不在 Host 机器上的客户端提供预览。
> 范围:完成的轮次以其产出文件收尾的那一行、读得出是链接的文件路径链接,以及 Host 打开器对浏览器可渲染文档优先选用默认浏览器。经决定不在范围内:以 HTTP 提供工作区文件,以及为不在 Host 机器上的客户端提供预览。
## 问题
一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。
一个产出了文件的 web 会话,没有办法看到那个文件。agent(智能体)写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。
这背后是两个不同的缺陷。转录从不说明一轮产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的“做完了打不开”,是一个可发现性失败叠在一项本就可用的能力之上。
这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把变更行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的“做完了打不开”,是一个可发现性失败叠在一项本就可用的能力之上。
## 决定
**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**完成的轮次以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有该空位及其 owner 参数约定(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从变更工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,该轮次的产出都会被列出;新的变更工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一个轮次内按首见顺序只出现一次;累积在轮次边界重置,因此一个轮次若先变更文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。
**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。
**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。
**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,否则,把 `.html` 绑给编辑器的开发者点开一个产出的页面,得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。
**以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 更早的一版由 harness 自己提供文件——先是与 `/api` 同源,随后加上 `CSP: sandbox`,再后来交给一个以自身端口给所服务文档独立源的第二监听器。每一步都在回答一个真实问题,而整条线在产品范围定下之后被整体退役:不为“浏览器不在 Host 机器上”的场景提供预览。这一点定下之后,Host 打开器完整回答了受支持的场景,而那套 HTTP 机制回答的只是不受支持的那个。
## 考虑过的替代方案
- **由 harness 提供 `/f/<sessionId>/<segments…>`**——已经实现并可用,包含双 `realpath` 收敛、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状契约。
- **由 harness 提供 `/f/<sessionId>/<segments…>`**——已经实现并可用,包含通过双重 `realpath` 实现的路径约束、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状约定。
- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到 35 KB 的全部会话转录,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。
- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。
@@ -32,4 +32,4 @@ Status: implemented
## 影响
现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。
现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。Markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。

View File

@@ -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/feature/2026-07-31-workspace-write-surface-default.md
2026-07-31-workspace-write-surface-default.md: e6a36ad4ee7179cabf958114681728c3ac7340b4
2026-07-31-workspace-write-surface-default.zh.md: 5ced928b167892d9abe1f4423da72a0243590735
2026-07-31-workspace-write-surface-default.zh.md: fd18f2b69eb40df8392afb50892c7740d0e30af0

View File

@@ -14,7 +14,7 @@ Status: implemented
真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝方式关闭,不会退回不受限命令。
该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝告终,不会退回不受限命令。
## 测试
@@ -26,10 +26,10 @@ Status: implemented
**保留不受限的 TUI,只更改浏览器回退值。** 不予采纳,因为这会保留无法解释的界面差异,并让全新的终端会话继续拥有本决策要移除的权限。
**在同一次变更中添加终端审批对话框。** 不予采纳,因为这是另一个交互与生命周期决策。TUI 没有 `approval/request` 应答者,因此一次性自动升权当前会落定为不可用并以拒绝方式关闭;需要更宽权限的用户可以通过 `/permission` 主动选择其他 preset。
**在同一次变更中添加终端审批对话框。** 不予采纳,因为这是另一个交互与生命周期决策。TUI 没有 `approval/request` 应答者,因此一次性自动升权当前会落定为不可用并以拒绝告终;需要更宽权限的用户可以通过 `/permission` 主动选择其他 preset。
## 后果
全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。
由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝方式关闭,而不会显示权限询问。
由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝告终,而不会显示权限询问。

View File

@@ -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/feature/2026-08-01-pwsh-tool-and-executor.md
2026-08-01-pwsh-tool-and-executor.md: 7206f8ffe6640f8499f8453c40ab5846b23112c6
2026-08-01-pwsh-tool-and-executor.zh.md: 5a48adb79fed209d2d2ecb9514fd51538491f04c
2026-08-01-pwsh-tool-and-executor.zh.md: bdc2563000a917cc1672a378c9e390354411c41c

View File

@@ -6,30 +6,30 @@ Status: implemented
## 问题
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先画像所需:后台任务、沙箱升级与持久 PTY 孪生都是 bash 形状的表面,最小化的 `pwsh` 工具不该背负。
harness 在每个平台只说一种 shell 方言:`bash`。Windows 主机只能通过 WSL 或 Git-Bash 垫片运行它,而交付的 `dsh-bash-local` 执行器仅限 POSIX(硬编码 `bash`,进程组语义是 POSIX 的)。Windows 路线图——让主机默认 `pwsh`,之后再做 pwsh TUI/GUI 渲染——没有执行基础:既没有 bash 执行器 seam 的 PowerShell 实现,也没有教模型 PowerShell 方言的面向模型工具。bash 工具本身也远大于 Windows 优先配置所需:后台任务、沙箱升级与持久 PTY 对应工具都是针对 bash 设计的功能面,最小化的 `pwsh` 工具不该背负。
## 决策
在 `packages/bash/` 下新增两个包:
- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 从配置默认化并设上限,`run()` 通过一个 deadline 融合配置夹取的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为 ONE argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,契约是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、减去 sandbox 面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。parity 决策取代了本 note 的最小画像工具描述。
- **`@deepseek-ai/dsh-pwsh-local`** —— `ctx.bash` 执行器 seam 的本地实现,基于 `ctx.subprocess`,逐调用镜像 `dsh-bash-local`:`resolve()` 根据配置设置默认值和上限,`run()` 通过一个 deadline 融合受配置上限约束的超时与调用方信号,`start()` 返回消费式后台句柄,其进程归属于 subprocess 服务。命令字符串作为单个 argv 元素传给 `pwsh -NoLogo -NoProfile -NonInteractive -Command`,由 PowerShell 解析,不存在 shell 引号层。可执行文件解析(`resolvePwshPath`)是 `(configured, env, platform)` 的纯函数:先显式配置,再在 Windows 上探测 PowerShell 7 安装位置、PATH 条目(剥离引号)与 Windows PowerShell 5.1,否则经 PATH 解析裸 `pwsh`。
- **`@deepseek-ai/dsh-tool-pwsh`** —— 基于 `ctx.bash` 的面向模型工具,约定是 PowerShell 方言,逐调用镜像 `dsh-tool-bash`、不含沙箱功能面:经通用任务运行时执行前台与 `run_in_background`,经共享 [`dsh-bash-env`](../feature/2026-08-02-pwsh-tool-bash-parity.md) 注册表管理 `DSH_*` 环境,以及 bash 的 marker/截断渲染机制(正常退出不产生 marker)。parity 决策取代了本 Agent Note 的最小配置工具描述。
Windows vitest 覆盖率刻意不属本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
Windows vitest 测试覆盖刻意不纳入本次改动:仓库的 Windows CI 通道负责构建/静态门禁,单元覆盖在 Linux 上运行,两个包的套件在那里以真实 `pwsh` 运行(GitHub 托管 runner 预装)或缺失时自行跳过。vitest 的 `windowsUnsupportedPackages` 排除从 `packages/bash/*` 收窄为真正需要 bash 的包,使 pwsh 套件也能在 Windows 开发机上原生运行。
本决策之后的路线图——让 Windows 主机默认 `pwsh`(关闭 bash)与 pwsh TUI/GUI 渲染——另行记录为[提案](../../proposed/feature/2026-08-01-windows-pwsh-default.md)。
## 备选方案
**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号实情、引号域)应各自归入自己包的文档。
**给 `dsh-bash-local` 增加 pwsh 模式。** 否决:执行器的身份就是它 spawn 的 shell;在一个包内塞第二种方言会翻倍配置面(`shell` 开关)与测试矩阵,且两种方言的怪癖(Windows 上的信号信息、引号域)应各自归入自己包的文档。
**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台/沙箱表面是 bash 形状的;`pwsh` 模式要么隐藏它(条件 schema 翻动),要么继承它(把最小画像明确拒绝的表面带进来)。最小孪生让模型契约保持诚实。
**给 `dsh-tool-bash` 增加方言参数。** 否决:bash 工具的后台任务与沙箱功能面都是针对 bash 设计的;`pwsh` 模式要么隐藏它(条件 schema 反复变动),要么继承它(把最小配置明确拒绝的功能面带进来)。精简版对应工具能让模型约定如实反映实际功能。
**现在就接入交付的 CLI 组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接线点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
**现在就接入交付的 CLI(命令行界面)组合。** 否决:在 Windows 默认决策落地前把 `tool-pwsh` + `pwsh-local` 挂进 `base.cordis.yml` 会改变交付清单;本改动交付能力与接入点(`apps/cli` 依赖、tsconfig 工程),不切换任何默认。
## 后果
- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范契约一致,因此 `tool-pwsh` 之外的面向模型消费方(hooks 桥、进程内插件)无需方言垫片即可运行 PowerShell。
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(减 sandbox)上与 bash 工具行为可互换,提示词指导精确陈述 marker 契约。
- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态实情仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。
- CLI 增加两个 workspace 依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。
- bash 执行器 seam 有了第二个、Windows 原生的实现,请求/规范约定一致,因此 `tool-pwsh` 之外的面向模型消费方(钩子桥、进程内插件)无需方言垫片即可运行 PowerShell。
- `tool-pwsh` 是模型可见的 Windows 优先 shell 工具:在前台与后台工作(不含沙箱功能)上与 bash 工具行为可互换,提示词指导精确陈述 marker 约定。
- Windows 语义在平台差异处不同:强制终止报告退出码 1 且无信号(因此 `signal`/`killed` 状态信息仅限 POSIX),PowerShell 输出 CRLF,测试做归一化。
- CLI 增加两个工作区依赖与两个 tsconfig 工程,但不挂载任一插件——组合决策留给 Windows 默认提案。

View File

@@ -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/feature/2026-08-02-pwsh-tool-bash-parity.md
2026-08-02-pwsh-tool-bash-parity.md: e35a903892d5d50a0d3ca12b23daa53f26d6aade
2026-08-02-pwsh-tool-bash-parity.zh.md: d7a68f0cab5281b25c3321e7ffff61c358b21a0a
2026-08-02-pwsh-tool-bash-parity.zh.md: f4ee10484b6e49ebbaeb0b8ed4b0b30c5ac17e5c

View File

@@ -6,31 +6,31 @@ Status: implemented
## 问题
首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台、无后台任务、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事("恒打 `[exit code: N]`")。对该变更的 review 发现模型可见契约与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。
首个 Windows 原生基础交付的 `dsh-tool-pwsh` 是刻意最小的画像——仅前台(每次调用启动一个新进程;无持久 PTY 会话)、受管环境只有三个硬编码 `DSH_*` 键、以及一个未声明就偏离 bash 工具的 marker 故事(「恒打 `[exit code: N]`」)。对该变更的 review 发现模型可见约定与实现脱节:描述承诺了渲染器从未执行的 spill 路径报告,README 宣称了不存在的导出与工具未做的渲染,工具自己的测试还钉死了有损行为。最小画像还让 `DSH_*` contributor seam 因缺席而重复:向 `ctx.bashEnv` 贡献环境事实的插件对 pwsh 调用毫无作用。
## 决策
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,减去 sandbox 面,其模型可见文本精确描述这一行为:
`dsh-tool-pwsh` 现在逐调用镜像 `dsh-tool-bash`,沙箱相关接口除外,其模型可见文本精确描述这一行为:
- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` prompt section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash prompt 中与其自身渲染矛盾的 "every result" 措辞。
- **渲染完全采用 bash 故事**:stdout、带标记的 `[stderr]` 段、带 spill 路径的截断通知、空体渲染 `(no output)`、退出 marker 仅限非零退出——干净退出不产生 marker。描述与 `tool:pwsh` 提示词 section 精确陈述这一点("Non-zero exits are reported as `[exit code: N]` markers"),刻意不复制 bash 提示词中与其自身渲染矛盾的 "every result" 措辞。
- **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。
- **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁;因此,共享环境的所有权不属于任何一个面向模型的 shell 工具。
- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。
- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
- **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;提示词说明:Windows 强制终止以 exit 1 结算,且不带 signal marker。
- **范围外,不变**:沙箱升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。
## 备选方案
**保留最小画像,只修声明。** 否决:review 的核心发现是"从 bash 复制的文本契约在缺少对应实现时会漂移";最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。
**保留最小画像,只修声明。** 否决:review 的核心发现是「从 bash 复制的文本约定在缺少对应实现时会漂移」;最小工具加准确声明仍让 pwsh 调用没有后台执行、没有 contributor 对等、并留下一个必须永远重新辩护的偏离 marker 故事。
**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对契约改由各工具 README 记录。
**在加载时拒绝不匹配的执行器方言。** 合并前尝试过并撤回:在 `BashExecutor` 上加 `ShellDialect` 标记(`bash` | `powershell`),两个 shell 工具在挂载的执行器说另一种方言时抛错。它迫使每个执行器实现——包括每个测试与示例的 fake——都要声明 dialect,为一道仓内及合理部署中都没有目标可拦的护栏(交付组合总是把 tool-pwsh 配 `dsh-pwsh-local`、tool-bash 配 `dsh-bash-local`)给每个 shell 工具测试添噪。配对约定改由各工具 README 记录。
**提取完全共享的工具实现基座(抽象 shell 方言,两个薄叶子)。** 考虑后推迟:bash-env 提取与结构镜像(`render.ts`/`background.ts` 孪生)是它要立足的基础;在出现第三种方言或持久 PTY 孪生、让抽象的形态可观察之前,不做完整基座。
## 后果
- bash 与 pwsh 工具在前台与后台 shell 工作(减 sandbox)上行为可互换,pwsh 的 prompt/描述句每句都有渲染器背书——reviewer 的“拿代码 grep 对证”检查通过。
- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture 钉住。
- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。
- bash 与 pwsh 工具在前台与后台 shell 工作(沙箱除外)上行为可互换,pwsh 的提示词/描述句每句都有渲染器背书——reviewer 的「拿代码 grep 对证」检查通过。
- 对齐也反向发生过一次:pwsh 工具的结构化前台中止(`HarnessError('tool call aborted', TOOL_ABORTED)`,name 为 `AbortError`)被回移到 bash 工具,取代其无码的 `Error('command aborted')`——这是模型可见/入日志的变更,由两侧的精确形状测试与 cancel-tool-calls fixture(测试前置数据)钉住。
- `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(主干组合包已如此)。
- Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。
- pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。
- 路线图提案的 parity 阶段已交付;terminal 卡呈现阶段随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策交付(TUI 本身已移除),剩余阶段是 Windows 默认组合。

View File

@@ -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/feature/2026-08-02-session-search-not-shipped-default.md
2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a
2026-08-02-session-search-not-shipped-default.zh.md: 5e42a2c2323904117f9322b5c4a53c43c6ed3f2a
2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845

View File

@@ -6,20 +6,20 @@ Status: implemented
## 问题
[交付清单决策](2026-07-31-even-out-shipped-tool-rosters.md)把 `tool-session-query` 设为共享 [`cordis.patch.yml`](../../../../packages/bundle/base/cordis.patch.yml) 的默认行,于是交付的 TUI 与 Web surface 把这五个会话搜索工具(`session_search`、`session_event_search`、`session_trace`、`session_event_trace`、`session_event_read`)呈现给了模型。这与[面向模型的会话查询工具决策](2026-07-24-model-facing-session-query-tools.md)相抵触,该决策持需显式启用的立场,包 README 将其记录为「shipped host compositions do not mount it by default」。这份默认还交付了一个提示词段,向模型讲授一套既往工作搜索工作流,而没有任何用户要求过。
[交付清单决策](2026-07-31-even-out-shipped-tool-rosters.md)把 `tool-session-query` 设为共享 [`cordis.patch.yml`](../../../../packages/bundle/base/cordis.patch.yml) 的默认行,于是交付的 TUI 与 Web surface 把这五个会话搜索工具(`session_search`、`session_event_search`、`session_trace`、`session_event_trace`、`session_event_read`)呈现给了模型。这与[面向模型的会话查询工具决策](2026-07-24-model-facing-session-query-tools.md)相抵触,该决策持需显式启用的立场,包 README 将其记录为「shipped host compositions do not mount it by default」。这项默认设置还交付了一个提示词段,向模型讲授一套既往工作搜索工作流,而没有任何用户要求过。
## 决策
交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP 示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。
交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。
`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。
`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。
## 曾考虑的替代方案
- **把 `session-query-sqlite` 索引也一并移除**——否决,因为 `/resume` 和 Web 内容搜索框直接消费 `ctx.sessionQuery`;它们是宿主功能,不是模型工具,移除提供方会破坏它们。
- **保留该行,但在每个 overlay 中禁用它**——否决,因为一条被禁用的 base 行仍会交付依赖,而且一行就能轻易重新启用;已记录的 opt-in 立场要求消费方不出现在交付的 surface 上,以 ACP 示例作为挂载参考。
- **只在 TUI 上挂载**——否决,因为共享 base 是所有 surface 共用的一组行;surface 专属挂载会重新引入交付清单决策所消除的清单分裂。
- **把 `session-query-sqlite` 索引也一并移除**——否决,因为 `/resume` 和 Web 内容搜索框直接消费 `ctx.sessionQuery`;它们是宿主功能,不是模型工具,移除提供方会破坏它们。
- **保留该行,但在每个 overlay 中禁用它**——否决,因为一条被禁用的 base 行仍会交付依赖,而且一行就能轻易重新启用;已记录的 opt-in 立场要求消费方不出现在交付的 surface 上,以 ACP 示例作为挂载参考。
- **只在 TUI 上挂载**——否决,因为共享 base 是所有 surface 共用的一组行;surface 专属挂载会重新引入交付清单决策所消除的清单分裂。
## 后果
两个 surface 都回到同样的二十个无条件工具(ripgrep 可用时再加上 `glob`/`grep`),五个会话搜索 schema 及其提示词段也一并退出默认请求。两个 surface 上的交付组合测试都固定这份更小的目录,因此把会话搜索重新作为默认加回会触及同样的测试。想要会话搜索的用户从个人 overlay 或 ACP 示例挂载该消费方,并在挂载处添加依赖。
两个 surface 都回到同样的二十个无条件工具(ripgrep 可用时再加上 `glob`/`grep`),五个会话搜索 schema 及其提示词段也一并退出默认请求。两个 surface 上的交付组合测试都固定这份更小的目录,因此把会话搜索重新作为默认加回会触及同样的测试。想要会话搜索的用户从个人 overlay 或 ACP 示例挂载该消费方,并在挂载处添加依赖。

Some files were not shown because too many files have changed in this diff Show More