Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/subsystems/lsp.i18n.yaml
#	docs/subsystems/tasks.md
#	docs/subsystems/tasks.zh.md
#	packages/client/README.i18n.yaml
#	packages/client/runtime/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/tsconfig.json
#	packages/tasks/tasks/README.i18n.yaml
#	tsconfig.base.json
This commit is contained in:
Yichen Jiang
2026-08-09 13:49:34 +08:00
2090 changed files with 39587 additions and 13290 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 229feae568ba6e40a9c633696097eff46fd5bc95
README.zh.md: b84aef020a7e3edf305df709d399fbc7b093b6a3
README.md: 7589f37b174b94558bff9f74f860bb4b42705fb8
README.zh.md: 4e99dd9313cca89e84ae0bfaf4a2179d726da864

View File

@@ -6,7 +6,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and fun
## Hierarchy
Packages live at `packages/<group>/<pkg>/`; groups are containers, while names remain `@deepseek-ai/dsh-<pkg>`. **Each group README is the canonical package/ctx-key map.**
Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Group READMEs own package/ctx-key maps.**
| Group | Role | Release expectation |
|---|---|---|
@@ -16,6 +16,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`e2b/`](e2b/README.md) | E2B providers | POC |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
@@ -33,23 +34,20 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call `tools/execute` deadline enforcement | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene advisory repeat-call reminders | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection, temporary Plugins, restricted repository Plugin loading | Product — stable surface |
| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | JSON-RPC integration, approval/interaction seams, ask-user tool | Product — stable surface |
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased |

View File

@@ -6,7 +6,7 @@
## 层级结构
`packages/<group>/<pkg>/`组是容器,包名仍为 `@deepseek-ai/dsh-<pkg>`。**每个组 README 是规范的ctx 键映射。**
按组置`packages/<group>/<pkg>/`;包名仍为 `@deepseek-ai/dsh-<pkg>`。**组 README 负责ctx 键映射。**
| 组 | 职责 | 发布预期 |
|---|---|---|
@@ -16,6 +16,7 @@
| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定表面 |
| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定表面 |
| [`llm/`](llm/README.md) | LLM大语言模型能力系列抽象服务 + 提供方适配器 | 产品:稳定表面 |
| [`e2b/`](e2b/README.md) | E2B 提供方 | POC |
| [`subprocess/`](subprocess/README.md) | 进程管理能力系列spawn seam + 本地进程树实现 | 产品:稳定表面 |
| [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 |
| [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 |
@@ -33,23 +34,20 @@
| [`spill/`](spill/README.md) | 溢出能力系列:存储 seam、本地实现、工具结果溢出策略 | 产品:稳定表面 |
| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定表面 |
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
| [`timeout/`](timeout/README.md) | 工具调用 `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生建议性重复调用提醒 | 产品:稳定表面 |
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行 | 产品:稳定表面 |
| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定表面 |
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检、临时 Plugin、受限 repository Plugin 加载 | 产品:稳定表面 |
| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限 repository Plugin 加载 | 产品:稳定表面 |
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude CodeCodex 协议格式库 | 产品:稳定表面 |
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
| [`session-projection/`](session-projection/README.md) | 投影 seam领域折叠单元供给全量值 | 产品:稳定表面 |
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定表面 |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
| [`scaffold/`](scaffold/README.md) | 创建启动驱动项目的工具helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定表面 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定表面 |
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定表面 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/acp/README.md
README.md: 3ba247598f29f2244456061fe7f9a3282086148f
README.zh.md: c13bc05b12fff2ef97b4d547936aa14a6556430a
README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5
README.zh.md: fb9f419e23aef5b3bdf0bdd486b331eb2f3f236a

View File

@@ -2,10 +2,10 @@
English | [中文](README.zh.md)
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface.
| Package | Role |
|---|---|
| [`acp/`](acp/README.md) | Automation-only ACP server. |
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.
The server contract is documented in [`acp/README.md`](acp/README.md).

View File

@@ -2,10 +2,10 @@
[English](README.md) | 中文
ACPAgent Client Protocol组将 harness 中的 agent智能体公开给程序化客户端。它是互操作传输层而非展示或人机交互层。
ACPAgent Client Protocol通过该协议将 harness 中的 agent智能体公开给程序化客户端。它是互操作传输层不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口
| 包 | 职责 |
|---|---|
| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 |
与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器
服务器约定见 [`acp/README.md`](acp/README.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 packages/acp/acp/README.md
README.md: 9cc4a5e271c7200f6ad8799a4b8fa9e64b2ca893
README.zh.md: 82aa5df2c7d87312d4b619a09582cc0c2d884398
README.zh.md: eafae5602bdeb408ef548a9e706e059bd99bde17

View File

@@ -17,14 +17,14 @@
两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。
## 协议
## 协议约
| 方法 | 行为 |
|---|---|
| `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 |
| `authenticate` | 空操作,因为服务器不公布身份验证方法。 |
| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent接受空的 `additionalDirectories``mcpServers`,拒绝非空值。 |
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 |
| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入空闲状态。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 |
| `session/cancel` | 仅取消指定的 agent并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 |
| `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 |
| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许拒绝选项。客户端可以自动回答。 |
@@ -37,7 +37,7 @@
客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此仅 ACP 的插件重载不会遗留 agent。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入 idle 前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。
ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出agent 进入空闲状态前发生的 steering中途引导或注入工作也可能参与其中。因此因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即拒绝该提示词。
## 运行

View File

@@ -24,7 +24,7 @@
"path": "../../core/agent"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../support/invariants"

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/README.md
README.md: 7c75e8012459266e0ce09c97416d140e5ac777e1
README.zh.md: 87bd15fc4e5ad23ef785f7c9ee805a4aa1a35e46
README.zh.md: f4e882570d2dc929cca466e0bdb05bb2943f2c93

View File

@@ -9,9 +9,9 @@
| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` |
| [`gateway/`](gateway/README.md) | Host TypeRT 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` |
运行时依赖方向为 `remotes → gateway → connection → webserver`BFF 消费共享的 `TypeRTClientRemote` Gateway 把传输交给 ConnectionConnection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
运行时依赖方向为 `remotes → gateway → connection → webserver`BFF 消费共享的 `TypeRTClientRemote`Gateway 把传输交给 ConnectionConnection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。
## 已知限制与延期工作
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection``api/webserver` 下,而无需改变服务约。
- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection``api/webserver` 下,而无需改变服务约
- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver使已迁移与旧方法共用同一套 Agent/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 packages/api/gateway/README.md
README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502
README.zh.md: 6b5ccff2340405cc0045147239c5bd4f3eead7da
README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway``@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约,并将业务选择交给 API Remotes将传输、请求关联、信任和响应封装交给 Connection。
为 Host 与 Client 两侧的 Cordis 环境提供 TypeRT RPC endpoint。Host 入口提供 `ctx.typertGateway``@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor`,并将业务选择交给 API Remotes将传输、请求关联、信任和响应封装交给 Connection。
## Host 服务:`TypertGatewayService`ctx key`typertGateway`
@@ -20,7 +20,7 @@ Connection 可用时Host 入口会在 Connection 共享的 `/api` FetchHandle
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
生成的声明合并通过共享的 `TypeRTClientRemote` 约提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
生成的声明合并通过共享的 `TypeRTClientRemote`提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript 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 packages/api/remotes/README.md
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
README.zh.md: 7490d68781d3a7b0002b73fe06056ec86c144575
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a

View File

@@ -8,7 +8,7 @@
当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约,均可复用其 Client face。
本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote`,均可复用其 Client face。
## 构建边界

View File

@@ -21,7 +21,7 @@
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
"path": "../../session/session-persistence"
},
{
"path": "../../support/invariants"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/README.md
README.md: 601782caad24d3555a206365a3f1954d81af1cf0
README.zh.md: 8b96c4f80ba8776bfd8bdde178178cea946ff36c
README.md: 7395a051742af24b96842966b71297dc01853dca
README.zh.md: 043394f429d406a6d14f00b6d8a0384bd022abe0

View File

@@ -15,3 +15,5 @@ The capability family spans the canonical executor seam, its implementations, th
| [`tool-pwsh/`](tool-pwsh/README.md) | Exposes PowerShell execution to the model. | (registers on `ctx.tools`) |
A leaf `cordis.yml` selects one executor implementation and the model-facing tools it needs. A sandboxed composition also selects a `ctx.sandbox` provider; the [ACP example](../../examples/acp-agent/) shows one complete wiring.
The subsystem reference — request/spec vocabulary, results, background processes, the service, and events — is [docs/subsystems/bash.md](../../docs/subsystems/bash.md).

View File

@@ -2,16 +2,18 @@
[English](README.md) | 中文
该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。
该能力家族涵盖规范执行器 seam、其实现、共享 shell 环境和面向模型的工具。这些全是**产品**包。
| 包 | 职责 | ctx key |
|---|---|---|
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器约。 | `ctx.bash` |
| [`bash/`](bash/README.md) | 定义实现与消费方共享的执行器约。 | `ctx.bash` |
| [`bash-local/`](bash-local/README.md) | 通过本地 [`subprocess`](../subprocess/README.md) 服务执行命令。 | (注册 `ctx.bash` |
| [`bash-sandbox/`](bash-sandbox/README.md) | 在本地执行前应用已配置的 [`sandbox`](../sandbox/README.md) 后端。 | (注册 `ctx.bash` |
| [`pwsh-local/`](pwsh-local/README.md) | Windows 专用进程行为执行 PowerShell 命令。 | (注册 `ctx.bash` |
| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的`DSH_*` 环境。 | `ctx.bashEnv` |
| [`pwsh-local/`](pwsh-local/README.md) | 采用 Windows 特有的进程行为执行 PowerShell 命令。 | (注册 `ctx.bash` |
| [`bash-env/`](bash-env/README.md) | 提供 shell 工具共享的`DSH_*` 环境。 | `ctx.bashEnv` |
| [`tool-bash/`](tool-bash/README.md) | 向模型公开 Bash 执行和后台任务集成。 | (注册到 `ctx.tools` |
| [`tool-pwsh/`](tool-pwsh/README.md) | 向模型公开 PowerShell 执行。 | (注册到 `ctx.tools` |
叶节点 `cordis.yml` 选择一个执行器实现和所需的面向模型工具。沙箱化组合还会选择一个 `ctx.sandbox` 提供方;[ACPAgent Client Protocol示例](../../examples/acp-agent/)展示一套完整接线。
子系统参考——请求/spec 词汇、结果、后台进程、服务与事件——见 [docs/subsystems/bash.md](../../docs/subsystems/bash.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 packages/bash/bash-env/README.md
README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f
README.zh.md: b6f3aca41771f1ca990a4b708cd53119b8e8ad78
README.zh.md: e3103f32ce1d857267af9589619bed5538d2e6bf

View File

@@ -4,7 +4,7 @@
工具无关的 shell 环境插件:拥有 `ctx.bashEnv` 注册表,管理受信任的、每次执行收集的 `DSH_*` 变量,供模型可见的 shell 工具(`dsh-tool-bash``dsh-tool-pwsh`)收集进每次 shell 调用的环境。内置 shell 事实(`DSH_HOME``DSH_SHELL=1``DSH_SESSION_ID`归注册表自身所有其他插件可以注册额外的可枚举事实注册随插件纤维fiber释放重复所有权或未声明的运行时键会响亮失败。
包根导出 Cordis 插件约(`name``inject``Config``apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`
包根导出 Cordis 插件约`name``inject``Config``apply`)以及 `BashEnvRegistry` 服务类及其 contributor 类型;消费者在加载本插件后使用 `ctx.bashEnv`
## Config

View File

@@ -27,7 +27,7 @@
"path": "../../core/tools"
},
{
"path": "../../session-persistence/session-persistence"
"path": "../../session/session-persistence"
},
{
"path": "../../support/invariants"

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
README.md: 74c1e28f76db35603bb9f72e0522e16ece5589f7
README.zh.md: 2593049ce09c7bc1ae0a996321bb27cbac449977
README.zh.md: 34ad936f31bf4ce11e175653a3a895a56e4822cc

View File

@@ -18,7 +18,7 @@
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言即提供方在每次包装时加上的特征bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 归因是保守的。** 进程启动前,只有当调用方拥有的 workdir 经独立验证可用,并且 Node 报告 `ENOENT``EACCES`,且带有明确指向提供方 argv[0] 的来源信息时,才会将拒绝归因于 runner。这样可以识别缺失的 runner、不可执行的 runner或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessService` 同步抛出同样带有来源信息的 `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升引导。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent智能体调用提供回退。已批准的升权只更改该策略的模式会话根目录仍然附着其上。`resolve()` 把策略带入 spec因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制spawn、进程组终止、输出收集spill、后台句柄、凭证清理继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
README.md: 88f519a21a0889d6b7649502c51077940c23709f
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
README.md: 690f4e61740faf2648ecbc7f5995ec0fdaa64aee
README.zh.md: f8bcfce06a406eb94a2486e9825f2beb11b8fd5e

View File

@@ -31,7 +31,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).

View File

@@ -31,11 +31,11 @@
`BashExecRequest`command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult``start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
`stdin` 与普通 `env` 由同进程插件hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR``CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选缺失表示没有输入overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash``renderResult``dsh-tool-pwsh``renderPwshResult` 追加的 `[exit code: N]``[killed by signal: X]` marker 逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与退出状态 pill放在 seam 上,两个工具便永远不会在 marker 契约上漂移
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的一部分:它对 `dsh-tool-bash``renderResult``dsh-tool-pwsh``renderPwshResult` 追加的 `[exit code: N]``[killed by signal: X]` marker 进行逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与退出状态 pill位于 seam 上,因此两个工具 marker 约定不会发生偏离
## 模型体验

View File

@@ -119,6 +119,8 @@ describe('spawn construction (pure, every platform)', () => {
/** A subprocess service that records spawn specs and settles instantly. */
class CapturingSubprocessService extends SubprocessService {
specs: SubprocessSpawnSpec[] = []
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('pwsh spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
README.md: 47101e1198d13518c3d82877df8726c1fbf26b82
README.zh.md: d60ac4b3826838e875f7d43bc314f62e1453c1d9
README.zh.md: c7e0b3ffdc02705bbbd6fd6bd9b8cce2548445f3

View File

@@ -4,9 +4,9 @@
模型侧 `bash` 工具,注册在 `ctx.bash` 执行器 seam 上。前台执行始终位于该 seam 之后;后台进程句柄会注册到通用 `ctx.tasks` 运行时,并通过 `task_output``task_list``task_kill` 控制;这些工具由 `@deepseek-ai/dsh-tool-tasks` 提供。
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约是 bash 方言——请挂载能解析 bash 的执行器。
需要加载执行器实现(例如 `@deepseek-ai/dsh-bash-local`)与 [`@deepseek-ai/dsh-bash-env`](../bash-env/README.md) 注册表;在每个注入服务就绪之前,插件会保持等待状态(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。工具约是 bash 方言——请挂载能解析 bash 的执行器。
package根只公开 Cordis 插件约(`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部。
package根只公开 Cordis 插件约`name``inject``Config``apply`);结果渲染和后台进程适配仍保留在包内部。
插件还会提供 `tool:bash` 提示词段落(顺序 105检查每个结果中的 `[exit code: N]` 标记,发现失败时先调查原因再继续。
@@ -28,7 +28,7 @@
### 托管 shell 环境
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
每次模型发起的前台或后台 bash 调用都会通过共享的 [`dsh-bash-env`](../bash-env/README.md) 注册表收到新收集的一组可信 `DSH_*` 环境变量:`DSH_HOME`Harness home 绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及当活跃持久化后端能定位时的 `DSH_SESSION_JSONL`。注册表约——贡献方注册、重复/未声明键的响亮失败、内置项保留与贡献方示例——住在该包的 README 里。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;本地执行器会先删除继承的所有 `DSH_*` 再合并,因此嵌套 harness 和并发的父/子 agent 不会泄漏陈旧身份,且绝不修改 `process.env`。工具说明只教授通用 `$DSH_*` 约定,不会点名持久化专用变量,也不会添加永久的系统提示词段落。
结果文本依次包含 stdout、可选的 `[stderr]` 段落和适用的沙箱拒绝、超时、信号、退出代码及截断标记。超时与最终退出状态分别报告;非零退出仍是由模型解释的结果,不会成为 `isError`。截断结果会链接安全的完整 spill 文件,或报告文件不可用。只有 spawn 错误和中止等基础设施故障才会产生 `isError`
@@ -52,7 +52,7 @@
## 逐会话模式切换
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
对于启用沙箱的执行器,每次调用依次按单次升权、会话覆盖、执行器默认值解析模式。未启用沙箱以及没有 agent 的调用不携带会话覆盖。策略归属方贡献当前且不区分具体能力的常驻模式;拒绝结果仍负责操作特定的有效模式与重试引导。参见 [`dsh-bash` 整合](../bash/README.md)和[沙箱切换约](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
## 模型体验

View File

@@ -39,7 +39,7 @@
"path": "../../bash/bash-env"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../sandbox/sandbox"

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
README.zh.md: 54614fcfad1cdec5866aa3cc96e52983f4eb1642

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker
需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。
包根只导出 Cordis 插件约(`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
包根只导出 Cordis 插件约`name``inject``Config``apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。
插件还贡献 `tool:pwsh` prompt sectionorder 105非零退出以 `[exit code: N]` marker 报告Windows 上的中断以无 signal 的 exit 1 结算。
@@ -120,5 +120,5 @@ ack 是固定短行;任务输出按读取有界。
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器bash 工具的 sandbox 面不被镜像)。
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`PTY 后端目前仅限 Linux/macOSWindows ConPTY 持久 shell 属于路线图工作。
- **PowerShell 方言约** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **PowerShell 方言约** — 模型必须写 PowerShell原生路径、`$env:` 变量),而不是 bash没有方言翻译。
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/README.md
README.md: 15754410a4a81eb3fc898dd55269ddd1637e1dab
README.zh.md: 4023b80085998f57ed321bfda3a0abdd08b70a28
# pnpm run verify-translation-pairing --write packages/boot/README.md
README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35
README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56

11
packages/boot/README.md Normal file
View File

@@ -0,0 +1,11 @@
# boot/ — shared app-bin boot glue
English | [中文](README.zh.md)
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
| Package | Role | ctx key |
|---|---|---|
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md).

View File

@@ -0,0 +1,11 @@
# boot/:共享的 app bin 启动粘合层
[English](README.md) | 中文
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
README.md: c256b89288e3e384c1dd3e64629a06d7cfef31f6
README.zh.md: 88d1c4ad0ced2f5a6440a1b64e34e738a842f938
README.zh.md: 454d504be72c2120ec4aeaa1b22545e7d6ba2fee

View File

@@ -31,6 +31,8 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper构建后的消费方仍使用普通 Node 包解析。
<a id="profiles"></a>
## Profile
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录Harness home 由 [`resolveDshHome`](../../util/paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则大声失败。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而 pnpm 从不管理随安装内置的包。`PROFILE_TEMPLATES``web``headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会大声失败(即 `dsh plugin` 路径)。

View File

@@ -641,7 +641,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
/**
* Value mirrors used because Cordis's const enum has no runtime object to import.
* Keep aligned with `packages/cordis/tool-cordis/src/fiber-state.ts` and
* Keep aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts` and
* `packages/client/web/src/loader-status.ts`.
*/
const FIBER_PENDING = 0 as FiberState.PENDING

View File

@@ -36,14 +36,14 @@ describe('RepositoryCache', () => {
calls.push(directory)
await fakePackage(directory)
}
const cache = new RepositoryCache(root, install)
const cache = new RepositoryCache(root, { install })
const specifier = 'github:owner/repository#0123456789abcdef'
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
expect(concurrent).toBe(first)
expect(calls).toHaveLength(1)
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } })
expect(await reopened.resolve(specifier)).toBe(first)
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
@@ -68,8 +68,8 @@ describe('RepositoryCache', () => {
const specifier = 'github:owner/repository#race'
const [first, second] = await Promise.all([
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
])
expect(second).toBe(first)
@@ -80,11 +80,11 @@ describe('RepositoryCache', () => {
it('removes a failed staging tree and permits an exact retry', async () => {
const root = await temporaryRoot('repository-retry')
let attempts = 0
const cache = new RepositoryCache(root, async (directory) => {
const cache = new RepositoryCache(root, { install: async (directory) => {
attempts += 1
if (attempts === 1) throw new Error('install failed')
await fakePackage(directory)
})
} })
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
expect(await readdir(root)).toEqual([])
@@ -94,7 +94,7 @@ describe('RepositoryCache', () => {
it('rejects empty or padded specifiers before touching the cache', async () => {
const root = await temporaryRoot('repository-input')
const cache = new RepositoryCache(root, fakePackage)
const cache = new RepositoryCache(root, { install: fakePackage })
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
await expect(readdir(root)).resolves.toEqual([])
@@ -107,35 +107,69 @@ describe('RepositoryCache', () => {
const entry = join(root, key)
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } })
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
})
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'build-helper'), { recursive: true })
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
private: true,
version: '1.0.0',
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
})}\n`)
await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n')
await writeFile(join(repository, 'pnpm-lock.yaml'), [
"lockfileVersion: '9.0'",
'settings:',
' autoInstallPeers: true',
' excludeLinksFromLockfile: false',
'importers:',
' .: {}',
'',
].join('\n'))
await writeFile(join(repository, 'build-helper', 'package.json'), `${JSON.stringify({
name: 'repository-build-helper',
version: '1.0.0',
bin: 'index.js',
})}\n`)
await writeFile(join(repository, 'build-helper', 'index.js'), [
'#!/usr/bin/env node',
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepare: 'node prepare.mjs' },
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
devDependencies: {
'repository-build-helper': 'file:../build-helper',
'repository-prepare-helper': 'file:../prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'))
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
await execFileAsync('git', ['add', '.'], { cwd: repository })
await execFileAsync('git', [
@@ -149,6 +183,7 @@ describe('RepositoryCache', () => {
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/README.md
README.md: 4759170435a80e85731446cef21d24fff2abed66
README.zh.md: 1ef610a1b7b3c591c9a900e04f2d8096b0b086b9
README.md: af06fe921a66378894a4ab0174ef403499f5f68c
README.zh.md: b9656649782a638a3b39b4e1e6d330476b0732ba

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../ui/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Profile 组合包:在 manifest元数据清单中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile ](../ui/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
Profile 组合包:在 manifest元数据清单中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。
| 包 | 职责 | ctx key |
|---|---|---|

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: dc35cb4b596b265b70cea81aa5d6784fc1eff65b
README.zh.md: 0ffc5cdaf1a98e5df11ef042c7d15a994515c170
README.zh.md: bd14fe03d222f788563df0b562f86446df70b827

View File

@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
#### 模型看到的内容
`surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 98向模型说明 GUI规范的本地 URL、「this page」指代什么、当前模式下 HMR热模块替换重建的更新约,以及不要启动替代服务器的指令。`DSH_WEB_URL``DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
`surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 98向模型说明 GUI规范的本地 URL、「this page」指代什么、当前模式下 HMR热模块替换重建的更新约,以及不要启动替代服务器的指令。`DSH_WEB_URL``DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。
#### Token 影响

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/README.md
README.md: 347a42e0671ad0ae1da6c5488f94f387e85fcf99
README.zh.md: 596ce3910430a183a9750c94b83cb79f43ce037f
README.md: bf153cc2220a6615ad895d4b48340c3d953f6068
README.zh.md: 57198ae1e326f49aa460b9605a1db4982864a38f

View File

@@ -39,3 +39,5 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
The subsystem reference is [client-modules.md](../../docs/subsystems/client-modules.md); the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive slot model, and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.

View File

@@ -38,4 +38,6 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
每个子文档负责自身的约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
每个子文档负责自身的约和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
子系统参考是 [client-modules.md](../../docs/subsystems/client-modules.md)[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威 slot 模型,[web 客户端架构说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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 packages/client/connection/README.md
README.md: 161e34c4b6018625fb690e178eb9a9f8ac0ef21b
README.zh.md: d17012cc89c02a1b11f16d126b7c0cafe67fb2a0
README.zh.md: 9bb9405a0e7be5cdbe51b5099e33e2b85225d2fa

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议约见 api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约类型、`AbstractApiClient` seam以及循环的 sink配置类型。浏览器载体以 HTTP POST 发送 unaryrespond并为 `events.mux``events.host` 各开一条只下行的 WebSocket进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议约见 api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
prompt: request => Promise.resolve(ok(request, {
messageId: `fixture-message-${request.payload.childSessionId}` as never,
})),
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
@@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'subagent.list': return this.api.subagents.list(request)
case 'subagent.history': return this.api.subagents.history(request)
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)

View File

@@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient {
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
messageId: 'fake-message' as never,
}))),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
accepted: true as const,
}))),
}
readonly host: IApiClient['host'] = {

View File

@@ -16,7 +16,7 @@
"path": "../../core/session"
},
{
"path": "../../ui/commands"
"path": "../../interaction/commands"
},
{
"path": "../../util/brand"
@@ -28,10 +28,10 @@
"path": "../../host/webserver"
},
{
"path": "../../ui/user-approval"
"path": "../../interaction/user-approval"
},
{
"path": "../../ui/user-interaction"
"path": "../../interaction/user-interaction"
},
{
"path": "../../support/invariants"

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/hmr/README.md
README.md: 454c03cc3cd11722943efd025d164d9ca8233d25
README.zh.md: fc4100c48e5db9ed6781117dd652232bbd7c7aaa
README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
为通过外部脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
浏览器侧订阅系统 SSEServer-Sent Events通道`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate``prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose资源释放之前执行仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载fiber 的激活 epoch 会串联其服务提供方的 uid因此替换提供方 fiber 会级联所有依赖方无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash缺失行保持 dirty只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR热模块替换无需 builder→host 通道。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
README.md: 7d661c806955d0fac021dd6620994aab83c0f773
README.zh.md: a1da42a552dbe8770fcb78bf458c01a0057ce8dd
README.zh.md: aec59474fbf01857380709ebecb6ae99a6ca8174

View File

@@ -8,7 +8,7 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`app-shell→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 加载外部 classic script + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子(只加载脚本并注册 factory并发调用共享一个进行中的任务`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新加载脚本;它是 HMR热模块替换钩子。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件及其 sourcemap。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这一构建后的客户端导出;缺失文件共享一条构建说明,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
## 模型体验

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 5abe72767aa76d5690b3f287391bc8924bc62d03
README.zh.md: 78024fa095a5bcddc50ee121eec64e887f241ba7
README.zh.md: ee30f0b0de2202a02563b6008206586510d8de36

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed``settings/changed``credentials/changed``models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent智能体和 cwd客户端不持有任何实体化之前的会话状态——agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf``useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Slot 声明注入
`ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose资源释放回调 effect重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此generator 可以 yield 多个 `slots.register()` 调用并将它们组成一项事务setup 失败会回滚先前 yield 的 effectteardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch声明代次因此即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。
回调返回一个同步 disposer 或由多个 disposer 构成的 iterable。因此generator 可以 yield 多个 `slots.register()` 调用并将它们组成一项事务setup 失败会回滚先前 yield 的注册项teardown 则按逆序运行它们。声明生命周期使用专用的单调 declaration epoch声明代次因此即使折叠与重新声明合并在同一次 renderer 通知中,回调仍会重启,而普通条目变更不会重启它。声明绑定的 teardown 与账本变更同步运行,在同一 tick 内的后续注册之前释放运行时资源。详见 [slot 声明注入决策](../../../.agents/notes/implemented/architecture/2026-08-05-slot-declaration-injection.md)。
## Workspace 与 Session 列表
@@ -18,7 +18,7 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set。它是全快照状态`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影在当前 selection 落入归档集合时统一清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
`WorkspaceListState.archivedSessionIds` 镜像 Host 的注册表级全局归档集合(一个按 Host 顺序的 `readonly SessionId[]`,仅在成员变化时才替换;需要 O(1) 查询的消费方自建临时 Set。它是全快照状态`workspace.list` 基线、`archiveSession` 一元回声和 `host/archived-sessions-changed` 帧各自安装完整集合。`WorkspacesService.archiveSession(sessionId)` 通过 wire 归档;投影扫描在当前 selection 落入归档集合时将其清空为 New Session 视图状态——一条规则同时覆盖本地回声、其他标签页的帧、以及重连基线恢复出一个离线期间被归档的 selection。在 `workspace.list` 请求进行中安装的集合还会取代该过期基线携带的集合。各分组视图在所有位置隐藏集合成员,而会话行本身仍留在列表 store 中。
SlotsService 分别为 renderer 提供 `useSessions``useWorkspaces` 的裸 observableweb-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
@@ -38,9 +38,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口。每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并在与之匹配的 `user/message` 落地时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
## 请求检查
@@ -56,7 +56,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 模型重试投影
Session 对象会在事件 wire 边界依据生产方的完整字段约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
Session 对象会在事件 wire 边界依据生产方的完整字段约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的约束。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或被 dispose 时,会将该提示标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限always mode 提示则保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败则只保留该次尝试的重试提示。窗口重建与历史回放应用相同的投影,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 assistant 节点。
## 会话 fork

View File

@@ -281,17 +281,22 @@ export class Session implements SessionFace {
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot).
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
@@ -300,7 +305,9 @@ export class Session implements SessionFace {
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
} catch (error) {
result = transportError(error)
}

View File

@@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient {
onSubagentPrompt: (payload: unknown) => Promise<RpcResponse<{ messageId: never }>>
= () => Promise.resolve(ok({ messageId: 'fake-message' as never }))
onSubagentInterrupt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>>
= () => Promise.resolve(ok({ accepted: true as const }))
readonly subagents: IApiClient['subagents'] = {
list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)),
history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)),
prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)),
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)),
}
readonly host: IApiClient['host'] = {

View File

@@ -639,7 +639,7 @@ describe('paging', () => {
})
describe('prompt and cancel errors', () => {
it('routes an addressed child through non-activating history and continuation prompt only', async () => {
it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
@@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => {
const cancelled = await session.cancel()
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 },
])
@@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => {
content: [{ type: 'text', text: '继续' }],
},
])
expect(api.callsOf('subagent.interrupt')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
])
expect(api.callsOf('session.history')).toEqual([])
expect(api.callsOf('session.prompt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
// A successful interrupt leaves no stop error behind.
expect(session.getSnapshot().promptError).toBeNull()
expect(session.getSnapshot().subagent).toEqual({
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID },
}) as never)
const session = new Session(SID, api, {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const cancelled = await session.cancel()
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } })
expect(session.getSnapshot().promptError).toMatchObject({
op: 'stop', error: { code: 'subagent-unauthorized' },
})
})
it('keeps one-shot history readable without exposing prompt or cancel transport', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, {
@@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => {
})
await session.open()
const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
const cancelled = await session.cancel()
expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } })
expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } })
expect(api.callsOf('subagent.history')).toEqual([
{ parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 },
])
expect(api.callsOf('subagent.prompt')).toEqual([])
expect(api.callsOf('subagent.interrupt')).toEqual([])
expect(api.callsOf('session.cancel')).toEqual([])
})
it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => {

View File

@@ -24,16 +24,16 @@
"path": "../../host/apiproxy"
},
{
"path": "../../ui/commands"
"path": "../../interaction/commands"
},
{
"path": "../../compact/compact"
},
{
"path": "../../session-projection/session-projection"
"path": "../../session/session-projection"
},
{
"path": "../../session-title/session-title"
"path": "../../session/session-title"
},
{
"path": "../../llm/llm"

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/schema-form/README.md
README.md: 716cc7ba3c24f3a4de081e2d26f803b905235fac
README.zh.md: 65b8767df84f90eedd70e9f8ac27d7d419f06f46
README.zh.md: 9473620da83e73f7a095694b2321f1ebc5f9c53d

View File

@@ -4,7 +4,7 @@
面向 settings 编辑器的 schema草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema`schema.toJSON()` 的 ref 信封);`rehydrateSchema``new Schema(json)` 将其还原rehydrate为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件Models 页围绕它在此探测到的字段手写自己的卡片);该包不含任何 React也不做任何渲染。
##
## 约
编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点object 属性按名称解析dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
@@ -20,4 +20,4 @@
- **重建 schema 会执行所收到的信封**——`rehydrateSchema` 会重建一个活的 schemastery 校验器,而 schemastery 通过 `new Function` 复活序列化过的 callback因此 schema 信封是可执行内容,而非惰性数据。只有信封来自提供该页面的同一受信任 host 时才安全;该协议没有跨信任边界使用的惰性表示。
- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息及其 `$.path`;它不会把错误映射到各个控件。
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)记录该权衡。
- **没有通用渲染器**——消费方在这些辅助函数上构建功能专用表单。[Web 配置面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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 packages/client/ui-command/README.md
README.md: bc7386c8fca3b5c623473328bee6322fa7295277
README.zh.md: 478ee4ccee4558c70075baa45ab34ac6e3d71617
README.zh.md: 54190ac9144b1bfc12ba84a47474311d5a5391ea

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。约:[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpacematchEnter 裁决钩子的 `/` 命令 source、三型派发executepopupSelectleadingInput以及面向业务包的 popupSelect 注册面。约[Web 命令业务面 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)``decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-louddecoration装饰则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claimspace / 带参 enter与生命周期记账被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput注册了 `CommandUiSpec` 的是 popupSelect其余全部是 execute。
@@ -12,7 +12,7 @@
`PopupSelectController``src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边。壳是打开期间持有焦点的瞬态层onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。
`/client` 导出表层是插件主体(`apply``inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的约类型;壳组件本身是 overlay 注册的内部实现。
`/client` 导出表层是插件主体(`apply``inject`)、`CommandService`、目录类和 popup 类及其状态类型,以及冻结的约类型;壳组件本身是 overlay 注册的内部实现。
## 模型体验

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 837edaa097d47ebfb72d027616a18fdfeed8a488
README.zh.md: 419799666dc0689f8fe754d4c9de6d5dcf7fb09c
README.md: f5cf9b72b4f8f4ce7b1a07c0c1e30f5213edf678
README.zh.md: b3ce1b66308213650505b8838b9b43cb9987c757

View File

@@ -34,13 +34,13 @@ Keyboard message submission resolves delivery from the addressed session's runni
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
## Model Experience

View File

@@ -8,7 +8,7 @@
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block输入栏就渲染同一个禁用的 textarea并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好知道某会话发不出消息的插件ui-model在没有适配器服务其路由时本就依赖本包因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约里的每个 block 都靠选模型来解除把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block输入栏就渲染同一个禁用的 textarea并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好知道某会话发不出消息的插件ui-model在没有适配器服务其路由时本就依赖本包因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约里的每个 block 都靠选模型来解除把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
视图环是一个 slot严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
@@ -30,17 +30,17 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 约:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`Cmd/Ctrl+Enter 则执行另一种行为Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')`:如果当前 next-step 窗口在接纳前关闭AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher而非附件入口它要求当前会话的 `SlashController` 基于 textarea 当前 selection只打开 `/` trigger 的 `command` source同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染machine face 均缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM大语言模型和工具的墙钟时间这些是关于「屏幕上有什么」的窗口作用域事实而非账目压缩compaction使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/``chat/``input/``queue/``settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
完成的一轮以一个 turn-tail 空位收尾chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot每轮一次、位于 `assistantActionsSeqs` 选出的 seq派发 `TurnTailOwnerProps`(快照节点、收尾 seq以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
完成的一轮以一个 turn-tail 空位收尾chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot每轮一次、位于 `assistantActionsSeqs` 选出的 seq派发 `TurnTailOwnerProps`(快照节点、收尾 seq以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中chat 视图向可选的 `chatFileMentions` servicectx.get由同一插件提供索取收尾消息的行内代码词表并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
## 模型体验

View File

@@ -317,6 +317,7 @@ export function apply(ctx: Context): void {
actions.select(target)
layout.openDetails()
},
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {

View File

@@ -13,7 +13,8 @@ import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ReasoningRow } from './ReasoningRow.tsx'
@@ -41,6 +42,8 @@ export interface AssistantMarkdownProps {
onFork?: ((seq: number) => void) | undefined
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
fileMentions?: ChatViewInjected['fileMentions'] | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -58,11 +61,25 @@ function copyText(blocks: readonly AssistantBlock[]): string {
/** Reasoning block as the Think variant summary row (figma 39:28304). */
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail,
fileMentions, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
// growing transcript: a settled turn's produced files are final, and a
// fresh identity per append would discard MarkdownText's cached parse for
// every settled closing message on every stream chunk. The window-prepend
// edge (a mid-turn window start later gaining earlier same-turn writes)
// leaves a mention unlinked until remount — never a wrong link.
const owner = turnTail?.owner
const mentions: MarkdownFileMentions | undefined = useMemo(
() => (owner === undefined ? undefined : fileMentions?.(owner)),
// Deliberately not `owner`: its identity changes per append while the
// seq-addressed vocabulary it yields does not.
[fileMentions, owner?.seq],
)
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
@@ -79,7 +96,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.

View File

@@ -263,7 +263,8 @@ function StreamingTail({ useSession, t }: {
* ordered root Tool call crosses the declared whole-Tool render seat.
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
fileMentions, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
@@ -535,6 +536,7 @@ export function ChatView({
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined}
t={t}
/>
)

View File

@@ -4,6 +4,7 @@ import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
@@ -160,6 +161,30 @@ export interface ConvViewOwnerProps {
onInspectDone?: () => void
}
/**
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
* (optional-service convention): the chat view asks it for a closing message's
* inline-code vocabulary and threads the result into MarkdownText. Absent
* service — the providing plugin composed out of cordis.yml — turns the
* surface off; the prose renders inert code.
*/
export interface ChatFileMentions {
/**
* Mention vocabulary for the closing message the owner currency names.
* @param owner - Turn-tail owner currency (nodes, closing seq, opener).
* @returns The resolver MarkdownText consumes, or undefined when the turn
* produced nothing worth linking.
*/
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
}
declare module 'cordis' {
interface Context {
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
chatFileMentions: ChatFileMentions
}
}
/**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
* and the closing assistant's anchor. Registrants derive their own facts
@@ -519,6 +544,13 @@ export interface ChatViewInjected {
}
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
forkAt: (seq: number) => void
/**
* Prose file-mention vocabulary for one closing message, from the optional
* {@link ChatFileMentions} service (resolved lazily per call, so composing
* the provider in or out takes effect live). Undefined when the service is
* absent or the turn produced nothing worth linking.
*/
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
}
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */

View File

@@ -12,6 +12,7 @@ export type {
} from './contract/views.ts'
export type { ConversationKey } from './locales.ts'
export type {
ChatFileMentions,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,

View File

@@ -17,6 +17,7 @@ export const zh = {
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '给智能体发消息',
'placeholder.unavailable': '会话不可用',
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.commands': '命令',
@@ -159,6 +160,7 @@ export const en = {
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message the agent',
'placeholder.unavailable': 'Session unavailable',
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.commands': 'Commands',

View File

@@ -10,7 +10,7 @@
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
row below, primary action controls bottom-right. Input width rides the
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
is a cap, not a fixed size — layout rule: the box shrinks with the center
column keeping its clearance). Hero variant = the same card centered in the

View File

@@ -83,11 +83,15 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// A continuable child without its live parent cannot accept human input,
// but its independent Stop below stays available while it runs.
const continuable = subagent?.address.mode === 'continuable'
const parentOffline = continuable && !subagent.parentAvailable
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live || blocked !== undefined
// inert no-workspace state, the machine faces absent (no session), or a
// parent-offline continuable child. An owner block also disables input;
// adjudicating and submitting render read-only so the draft stays visible.
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
@@ -350,11 +354,14 @@ export function InputBar({
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const ordinary = subagent === null
const stopping = running && ordinary
const primaryLabel = stopping ? t('input.stop') : t('input.send')
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
// child keeps Send as the primary action and exposes Stop independently so
// pointer users can queue follow-ups while its current turn is running.
const primaryStops = running && subagent === null
const interruptible = running && continuable
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (stopping) {
if (primaryStops) {
stop?.()
return
}
@@ -478,9 +485,11 @@ export function InputBar({
disabled={locked}
readOnly={machineBusy}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
placeholder={placeholder ?? (parentOffline
? t('placeholder.parentOffline')
: disabled
? t('placeholder.unavailable')
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
rows={2}
onChange={onChange}
onKeyDown={onKeyDown}
@@ -521,16 +530,32 @@ export function InputBar({
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
{interruptible && (
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={t('input.stop')}
disabled={stop === undefined}
onMouseDown={keepFocus}
onClick={stop}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
</button>
</Tooltip>
)}
<Tooltip label={primaryLabel} side="top" delayMs={500}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{stopping ? (
{primaryStops ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>

View File

@@ -180,6 +180,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
inspectCall,
chatScroll,
forkAt,
// Absent-service default; mention tests override with a real resolver.
fileMentions: () => undefined,
// Mirrors the real lookup chain (conversation namespace, then common).
t,
}
@@ -350,6 +352,45 @@ describe('chat-flow derivation', () => {
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
})
it('threads the injected file-mention vocabulary into the closing prose only', () => {
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
},
})
const h = makeHarness({
nodes: [
user(1, 'build it'),
assistant(2, 'writing `report.html` now', 1),
wrote(3, 'w', 'site/report.html'),
assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
],
turnEnds: new Map([[1, 4]]),
})
// Stub provider mirroring the real service: only produced files resolve.
h.props.fileMentions = owner => ({
resolve: (value) => {
if (value !== 'report.html') return undefined
return {
open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) },
label: '打开 site/report.html',
title: 'site/report.html',
}
},
})
const view = render(<h.ChatView {...h.props} />)
// Exactly one live mention: the closing message links, the mid-turn
// narration stays inert code, and the unknown file resolves to nothing.
const mentions = view.container.querySelectorAll('code button')
expect(mentions).toHaveLength(1)
const mention = view.getByRole('button', { name: '打开 site/report.html' })
expect(mention.getAttribute('title')).toBe('site/report.html')
fireEvent.click(mention)
// The vocabulary was built from the closing message's own owner currency.
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
})
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running
// semantics (input stays free; primary turns stop), the machine pending lock,
// semantics (input stays free; continuable children keep Send beside Stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -143,11 +143,14 @@ function bench(over?: BenchOptions) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const stopping = over?.running === true && over.subagent === undefined
const primaryStops = over?.running === true && over.subagent === undefined
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
`button[aria-label="${primaryStops ? '停止生成' : '发送消息'}"]`,
)!
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher }
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
return {
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher,
}
}
describe('Enter semantics', () => {
@@ -250,8 +253,8 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue')
})
it('running subagent primary admits a follow-up instead of exposing Stop', () => {
const { button, sink, stop } = bench({
it('running continuable subagent keeps Send beside an independent Stop', () => {
const { button, interruptButton, textarea, sink, stop } = bench({
running: true,
draft: '后续消息',
subagent: {
@@ -264,22 +267,53 @@ describe('running and lock semantics (queue cut 1)', () => {
},
})
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).not.toBeNull()
expect(textarea.disabled).toBe(false)
fireEvent.click(button)
expect(sink).toHaveBeenCalledWith('后续消息', 'queue')
expect(stop).not.toHaveBeenCalled()
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
const empty = bench({
it('parent-offline running continuable locks Send but keeps independent Stop usable', () => {
const { button, interruptButton, textarea, stop, view } = bench({
running: true,
draft: '',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'continuable',
},
parentAvailable: false,
},
})
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行')
expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(button.disabled).toBe(true)
expect(interruptButton?.disabled).toBe(false)
fireEvent.click(interruptButton!)
expect(stop).toHaveBeenCalledTimes(1)
})
it('running one-shot subagent never exposes Stop', () => {
const { button, interruptButton, stop } = bench({
running: true,
draft: '不可停止',
subagent: {
address: {
parentSessionId: 'parent' as SessionId,
childSessionId: SID,
mode: 'one-shot',
},
parentAvailable: true,
},
})
expect(empty.button.disabled).toBe(true)
expect(button.getAttribute('aria-label')).toBe('发送消息')
expect(interruptButton).toBeNull()
expect(stop).not.toHaveBeenCalled()
})
it('keeps both running subagent Enter gestures on Queue transport', () => {

View File

@@ -24,7 +24,7 @@
"path": "../runtime"
},
{
"path": "../../session-projection/session-projection"
"path": "../../session/session-projection"
},
{
"path": "../../llm/token-meter"
@@ -51,7 +51,7 @@
"path": "../../support/invariants"
},
{
"path": "../../ui/permission"
"path": "../../interaction/permission"
}
],
"exclude": [

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043
README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b
README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2
README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959

View File

@@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end
`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md).
The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md).
## Model Experience
None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request.
@@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests.
## Known Limitations and Deferred Work
- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up.
- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it.

View File

@@ -8,6 +8,8 @@
`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip文本为文件名完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。
收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service`producedFileMentions` 按精确路径解析行内代码 token或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。
## 模型体验
无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。
@@ -18,4 +20,4 @@
## 已知限制与暂缓事项
- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作
- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做

View File

@@ -6,18 +6,13 @@
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** Trailing path segment, the part that identifies the file at a glance. */
function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** Matched paths plus the opener and locale seats needed to present them. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]

View File

@@ -1,16 +1,18 @@
/**
* Deliverables plugin, browser half: registers the produced-files row into
* the chat view's turn-tail hole. All policy lives here — the derivation
* from the mutation tools' `locations`, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes the surface entirely; the
* owning view renders an empty hole at zero cost.
* the chat view's turn-tail chain, and provides the `chatFileMentions`
* service that links inline-code mentions of produced files in the closing
* prose. All policy lives here — the derivation from the mutation tools'
* `locations`, the mention matching, the chip cap, and the copy — so
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import { selectProducedFiles } from './turn-deliverables.ts'
import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
@@ -39,4 +41,17 @@ export function apply(ctx: ClientContext): void {
locale: NS,
}, ProducedFiles),
)
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.
const t = ctx.locale.bind(NS)
const mentions: ChatFileMentions = {
forClosing(owner) {
// Same claim test the turn-tail chain entry runs: no produced files,
// no vocabulary — the two surfaces agree by construction.
const paths = selectProducedFiles(owner)
if (paths === null) return undefined
return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
},
}
ctx.provide('chatFileMentions', mentions)
}

View File

@@ -4,6 +4,7 @@
* own follow-along `locations`, never the closing prose.
*/
import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
/**
@@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[
const paths = producedForClosing(nodes, seq)
return paths.length === 0 ? null : paths
}
/**
* Trailing path segment, the part that identifies the file at a glance.
* @param path - Slash- or backslash-separated path.
* @returns The final segment, or the whole string when separator-free.
*/
export function basename(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* File-mention vocabulary over one turn's produced paths, for the closing
* message's prose: an inline-code token opens the file it names. A token
* resolves by exact path, or by being exactly the basename of exactly one
* produced path — a basename two paths share stays inert rather than
* guessing, so a mention link can never open the wrong file or 404.
* @param paths - The turn's produced paths (tool order, already deduped).
* @param openFile - The chat view's file opener.
* @param label - Localizes the accessible open-label for a resolved path.
* @returns The resolver MarkdownText consumes; the full path rides `title`,
* the same disambiguator the row's chips carry.
*/
export function producedFileMentions(
paths: readonly string[],
openFile: (path: string) => void,
label: (path: string) => string,
): MarkdownFileMentions {
return {
resolve(value) {
const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value)
if (path === undefined) return undefined
return { open: () => { openFile(path) }, label: label(path), title: path }
},
}
}
/** The single produced path whose basename is exactly `value`, else undefined. */
function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined {
const matches = paths.filter(path => basename(path) === value)
return matches.length === 1 ? matches[0] : undefined
}

View File

@@ -13,9 +13,10 @@ import type {
AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
@@ -142,6 +143,33 @@ describe('ProducedFiles row', () => {
})
})
describe('producedFileMentions resolver', () => {
const label = (path: string) => `打开 ${path}`
it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => {
const opened: string[] = []
const resolver = producedFileMentions(
['out/index.html', 'a/style.css', 'b/style.css'],
(path) => { opened.push(path) },
label,
)
// Unique basename resolves to its full path; the full path rides title.
const byBasename = resolver.resolve('index.html')
expect(byBasename?.label).toBe('打开 out/index.html')
expect(byBasename?.title).toBe('out/index.html')
byBasename?.open()
expect(opened).toEqual(['out/index.html'])
// An exact path resolves even when its basename is ambiguous.
const exact = resolver.resolve('a/style.css')
expect(exact?.title).toBe('a/style.css')
// A basename two paths share stays unresolved rather than guessing,
// and so does a token naming nothing the turn wrote.
expect(resolver.resolve('style.css')).toBeUndefined()
expect(resolver.resolve('notes.md')).toBeUndefined()
expect(basename('a\\b\\c.txt')).toBe('c.txt')
})
})
describe('package shells', () => {
it('the node half mounts inert and the invariant companion registers ownership', async () => {
// The node half is deliberately inert; mounting it must simply not throw.
@@ -173,7 +201,24 @@ describe('plugin registration', () => {
await fiber.await()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1)
// The prose face is live while the plugin is: a produced turn yields a
// resolver whose matches open through the owner-supplied opener.
const opened: string[] = []
const owner = {
nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)],
seq: 3,
openFile: (path: string) => { opened.push(path) },
}
const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
const mentions = service?.forClosing(owner)
mentions?.resolve('report.html')?.open()
expect(opened).toEqual(['site/report.html'])
// A turn that produced nothing yields no vocabulary at all.
expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined()
await fiber.dispose()
expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
// Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
})
})

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