Merge remote-tracking branch 'origin/master' into codex/require-pr-labels

This commit is contained in:
Turtle
2026-07-25 14:23:57 +08:00
640 changed files with 13224 additions and 24120 deletions
@@ -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
2026-06-14-session-persistence.md: 2683bcd68e1f52fbd2bc78660fbe17e556a9a60d
2026-06-14-session-persistence.zh.md: 28e32925a5866fe520c0d9c3eb7b0647845d36f5
2026-06-14-session-persistence.md: 52434930bb662b0c97e61f7c2f69b67c309b6317
2026-06-14-session-persistence.zh.md: 143b58d32191108d7ba24b489bd4f898b1547aab
@@ -6,7 +6,7 @@ English | [中文](2026-06-14-session-persistence.zh.md)
## Problem
Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume ("continue yesterday's task"), durable forking, and the ACP `session/load` method ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) were all impossible.
Sessions lived only in memory. The example `session-jsonl.ts` plugin (duplicated byte-for-byte in both examples) was write-only telemetry: it buffered `session/event` and appended JSON lines, with no read/replay path, no crash-safety (no fsync, no atomic write, a fire-and-forget dispose drain), no listing, and no format versioning. Nothing could rehydrate a past session from disk into a live agent, so durable resume, durable forking, and host-side session browsing were all impossible.
The [event-sourced model](2026-06-11-event-sourced-sessions.md) makes the append-only log the single source of truth and derives LLM history from it. Persistence had to stay faithful to that: persist the existing `SessionEvent` directly, with no parallel "persisted message" type that the log is converted to and from. The backend also had to be swappable — a file store now, a database store later — behind one interface.
@@ -33,4 +33,4 @@ Format versioning: the header carries a `version`; `load` rejects any non-curren
## Consequences
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and the foundation the ACP `session/load` ([ACP support](../feature/2026-06-14-acp-agent-client-protocol.md)) needs — all over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.
Two new packages and the metadata seam in `dsh-session` (`session.header`, the `create(id?, options?)` signature). Bought: durable resume/fork, a read/replay path, crash tolerance, and host-side session access over the existing event-sourced log, with the backend swappable behind one interface. The reusable `runPersistenceContract` suite holds every backend to the same append-only, contiguous-seq, lazy-materialization, integer-metadata, and serializability semantics. Persisting the full log also settles event fidelity: `assistant/chunk` remains verbatim. SQLite initialization either commits its complete owned schema and header identity or leaves no partial schema to strand on the next open.
@@ -6,7 +6,7 @@ Status: implemented
## 问题
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复(「继续昨天的任务」)、持久 fork 以及 ACPAgent Client Protocol)的 `session/load` 方法([ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md)都无法实现。
会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、fire-and-forget 的 dispose 排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。
[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——统一在一个接口之后。
@@ -33,4 +33,4 @@ Status: implemented
## 后果
新增两个包(package),以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及 ACP `session/load`[ACP 支持](../feature/2026-06-14-acp-agent-client-protocol.md))所需的基础——全部基于现有事件溯源日志,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。
新增两个包(package),以及 `dsh-session` 中的元数据 seam`session.header``create(id?, options?)` 签名)。收益:持久恢复/fork、读取/回放路径、崩溃容忍,以及基于现有事件溯源日志的宿主侧会话访问,后端在一个接口之后可替换。可复用的 `runPersistenceContract` 测试套件以相同的仅追加、连续 seq、惰性物化、整数元数据与可序列化语义约束每个后端。持久化完整日志还确定了事件保真度:`assistant/chunk` 保持逐字节不变。SQLite 初始化要么提交完整的自有 schema 与 header 标识,要么不留下任何会使下次打开受阻的部分 schema。
@@ -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
2026-06-18-agent-lifecycle-and-ownership-seams.md: 70ebf1c6de97cb14e27377ec1f9bac18fc0766a6
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: 54d174aa325384f7c7492f072f10954f0b1d08c9
2026-06-18-agent-lifecycle-and-ownership-seams.md: f190b4ba2b7f22d29f473c8a2725401ff371488e
2026-06-18-agent-lifecycle-and-ownership-seams.zh.md: dcaa319232baa8951a4f515abc6bce5611da5576
@@ -18,7 +18,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
### 2. `AgentHandle` async disposer
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each fresh session's disposer in its `SessionRecord` and runs it on disconnect or plugin teardown, so a bare client disconnect leaves no registered agent and no session-store entry. A create that loses the close race disposes its unpublished handle.
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
@@ -30,7 +30,7 @@ Background-task ownership moved from a `tool-bash` plugin-local `Map<string, Age
These invariants hold and are pinned by tests:
- ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when `session/load` races teardown.
- ACP disconnect or plugin teardown leaves no registered agent and no session-store entry for any bridge-owned session, including a create racing connection closure.
- `session/cancel` before a queued prompt starts prevents that prompt from running; a later accepted prompt remains an independent queued turn.
- A `tool-bash` HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor).
- Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the `AgentLoop` plugin fiber.
@@ -18,7 +18,7 @@ ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se
### 2. `AgentHandle` 异步释放器
`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个会话的释放器,并在断连/拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目——即使 `session/load` 与拆除竞争(刚恢复的 handle 会在 closed-guard 抛出前释放)
`ctx.agents.create`/`resume`(以及 `AgentFactory` 接口)返回 `AgentHandle = { agent: Agent; dispose(): Promise<void> }`。释放器是一种**消费方能力**——仅持有裸 `Agent` 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个 memoize 的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 `disposed`)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 `AgentLoop` fiber 所有(handle 被丢弃)。ACP 在其 `SessionRecord` 中保存每个全新会话的释放器,并在断连或插件拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目。在与关闭的竞态中落败的创建流程会 dispose 其尚未发布的 handle
**拆除顺序对持久性至关重要**,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect`SessionStore.prepare`/`enter`/`announce`,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(`Promise.all`),这会让会话存储的 append 发布钩子移除与循环关闭时的 `session/flush` 竞争,从而丢失关闭的 `turn/end`;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + `await agent.done` 在会话分离之前),因此无论 handle 的 `dispose()` 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 `agent/disposed``session/disposed` 通知无法拒绝该链或跳过后续拆除。
@@ -30,7 +30,7 @@ ACPAgent Client Protocol)与 tool-bash 的若干限制是同一个缺失 se
以下不变式已经成立,并由测试固定:
- ACP 断连/会话关闭后,不留下该会话的任何已注册 agent 或会话存储条目,即使 `session/load` 与拆除竞争
- ACP 断连或插件拆除后,任何由桥接层拥有的会话都不留下已注册 agent 或会话存储条目,包括与连接关闭竞争的创建流程
- 已入队的提示词启动前执行 `session/cancel`,能阻止该提示词运行;后来接受的提示词仍是独立的已入队轮次。
- `tool-bash` HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。
- 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 `AgentLoop` 插件 fiber 所有。
@@ -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
2026-06-20-branded-ids.md: 93bab1d47c793cc4dd1f1d19fa3af22d1721be29
2026-06-20-branded-ids.zh.md: 6cc8a45717b547614a873d3f13c54c3950484a22
2026-06-20-branded-ids.md: 7c0b7ca89418e8312ec728223dac519f70edc3ed
2026-06-20-branded-ids.zh.md: 8b41ad3c3c85690fb03b20a208f8460a1614477b
@@ -12,7 +12,7 @@ The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared age
The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md).
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.
**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.
## Decision
@@ -12,7 +12,7 @@ harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `Cal
bash **owner token** 是相关的子情形:`BashExecRequest.owner?: string` 和 `BashExecSpec.owner: string | undefined``packages/bash/bash/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent(智能体)共享的 `Agent.id`/`SessionId``callerToken = (exec) => exec.agent?.id`,位于 `packages/bash/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。
**缺口 2*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、`ToolPresenter` 的 call-id map、ACP 的会话 id 记录和 loading set,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
**缺口 2*已经 brand* 的 ID 在 seam 处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP 的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。
## 决策
@@ -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
2026-06-20-extract-example-app-packages.md: 8e87a7164d9cc4789705b3d8fe83dc4978b4def0
2026-06-20-extract-example-app-packages.zh.md: bb5bf6a78d749bf2fc0637a48bff4496399a787d
2026-06-20-extract-example-app-packages.md: f2853db3f454d71572be003cfbf4f6dfd8377cdd
2026-06-20-extract-example-app-packages.zh.md: 58d3d95996b1dacbc12178b46524374429df71ed
@@ -42,7 +42,7 @@ The old `base*.yml`/`acp-tail.yml` includes already deduped the *config*, but a
- Example directories contain only their config, README, and tests: `start.ts`, the infrastructure preamble, and the shared YAML includes are gone.
- `demo:tui`, `demo:headless`, and `demo:acp` invoke the app-package bins.
- Each new package has a README and per-file 100% coverage; each app package also has a keyless real-Loader-path bin smoke that catches export-shape failures described in [postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md).
- The ACP replay transcript remains unchanged because the plugin set and load order did not change.
- The ACP replay suite boots through the app-package bin, so protocol wiring and assembled backend behavior cross the real Loader boundary.
## Consequences
@@ -42,7 +42,7 @@ Status: implemented
- 示例目录只包含配置、README 和测试:`start.ts`、基础设施前导和共享 YAML include 已移除。
- `demo:tui``demo:headless``demo:acp` 调用应用包的 bin。
- 每个新包都有 README 和逐文件 100% 覆盖率;每个应用包还有一个 keyless 的真实 Loader 路径 bin 冒烟测试,用于捕获[事后分析 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md) 中描述的导出形状故障。
- ACP 回放 transcript(文本记录)保持不变,因为插件集合和加载顺序未改变
- ACP 回放套件通过应用包的 bin 启动,因此协议接线与组装后的后端行为都跨越真实的 Loader 边界
## 后果
@@ -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
2026-06-20-generic-long-running-tool-runtime.md: 25668a6a699576e435670b9580385073f2f036fe
2026-06-20-generic-long-running-tool-runtime.zh.md: 3c214bb4309ea331ae2d4ba73c01df1ded8f40ef
2026-06-20-generic-long-running-tool-runtime.md: 0b901fcf928b900bd3a32f911e6e54a6a98076e2
2026-06-20-generic-long-running-tool-runtime.zh.md: e2860e3a91c06ec5110cd671b288e35c5d117f5d
@@ -69,7 +69,7 @@ A producer loaded without any control surface would let callers start work they
## Model-facing control surface
`dsh-tool-tasks` registers three kind-independent tools with generic ACP cards:
`dsh-tool-tasks` registers three kind-independent tools with generic UI cards:
- `task_output(task_id, wait?, timeout_ms?)` reads output and always appends `[status: ...]`. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Reads are non-blocking unless `wait: true`, whose timeout is defaulted and capped by plugin config. A wait timeout reports the still-running status and does not stop the task.
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`, or `(no background tasks)`.
@@ -69,7 +69,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访
## 面向模型的控制接口
`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 ACPAgent Client Protocol卡片:
`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 UI 卡片:
- `task_output(task_id, wait?, timeout_ms?)` 读取输出,并始终追加 `[status: ...]`。流式任务只返回上次读取以来的输出;最终输出任务在结算后返回结果。除非指定 `wait: true`,否则读取不会阻塞;等待超时由插件配置提供默认值并限定上限。等待超时会报告仍在运行的状态,不会停止任务。
- `task_list()` 将调用方可见的任务返回为 `<id> [<kind>] <status> — <label>`,没有任务时返回 `(no background tasks)`
@@ -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
2026-06-20-package-hierarchy.md: ab0732172934414f2361b9e4976b54e5daee298c
2026-06-20-package-hierarchy.zh.md: 57058860de5b4cba3e8a45ecad9d611d068fb82c
2026-06-20-package-hierarchy.md: 7cd07ff90225872f2a17b9a678e52fcee416b09a
2026-06-20-package-hierarchy.zh.md: 9ef89bd56144b39bb3240a22a2bb1e9216e24115
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-06-20-package-hierarchy.zh.md)
The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The uniform depth-two hierarchy remains the decision owned here.
The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here.
## Problem
@@ -36,8 +36,9 @@ packages/
session-persistence/
session-persistence-jsonl/
session-persistence-sqlite/
ui/ (product integration)
acp/ (product automation integration)
acp/
ui/ (human interaction and presentation)
support/ (dev/test/example infrastructure)
invariants/
ui-stdio/
@@ -49,7 +50,7 @@ packages/
- **Same-name nesting for capability families.** A family's interface package sits at `packages/<group>/<group>/` (`llm/llm`, `bash/bash`, `session-persistence/session-persistence`), with implementations and consumers as flat siblings. There is no extra `adapters/`/`impls/` sub-tier — every package is exactly depth 2, which keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package (unique dir names make first-on-disk-wins unambiguous).
- **`session` stays in `core/`; persistence is its own family.** The session log is core product API. Its storage backends form a parallel capability family (`session-persistence/`) mirroring `llm/` and `bash/`, rather than nesting under `core/session/`.
- **`agent-loop` is in `core/`.** It is the one concrete implementation of the `agent` seam, but it ships as the harness's default product loop, so it lives with the core spine. Plugins still depend on the `agent` vocabulary, never on `agent-loop`, so the loop stays swappable.
- **`invariants` and `ui-stdio` are `support/`, not product.** `invariants` is dev-mode contract checking. `ui-stdio` was extracted from the examples for reuse and the coverage gate — it is example-coupled, so it sits in `support/` alongside `llm-replay` (the snapshot-test replay adapter). `acp` is the only `ui/` member because it is a real product surface (the ACP bridge an editor drives), structurally distinct from the readline demo helper.
- **Product automation and human UI are separate groups.** `acp` is a product transport under `acp/`, while commands, approvals, interaction, and presentation adapters live under `ui/`. Dev-only invariants and replay infrastructure remain under `support/`.
### Deduplicating the package lists
@@ -70,7 +71,7 @@ Two doc-sync/hygiene gates keep the structure and its references honest, so the
- **A third tier (`adapters/` / `impls/` under each family)** — rejected: uniform depth 2 keeps the workspace glob a clean `packages/*/*` and lets one `@deepseek-ai/dsh-*` tsconfig wildcard resolve every package.
- **Nesting persistence under `core/session/`** — rejected: the storage backends form a parallel capability family mirroring `llm/` and `bash/`, while the session log itself stays core product API.
- **`ui-stdio` under `ui/`** — rejected: it is example-coupled dev support, not a product surface; `acp` is the only `ui/` member because an editor actually drives it.
- **`ui-stdio` under `ui/`** — rejected: it was example-coupled dev support, not a product surface.
## Consequences
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-06-20-package-hierarchy.md) | 中文
后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。这里拥有的决策仍是统一的二层目录深度。
后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。
## 问题
@@ -36,8 +36,9 @@ packages/
session-persistence/
session-persistence-jsonl/
session-persistence-sqlite/
ui/ (product integration)
acp/ (product automation integration)
acp/
ui/ (human interaction and presentation)
support/ (dev/test/example infrastructure)
invariants/
ui-stdio/
@@ -49,7 +50,7 @@ packages/
- **能力族使用同名嵌套。** 一个族的接口包位于 `packages/<group>/<group>/``llm/llm``bash/bash``session-persistence/session-persistence`),实现和消费方作为扁平兄弟并列。不设额外的 `adapters/`/`impls/` 子层——每个包恰好在深度 2,这使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包(唯一的目录名使 first-on-disk-wins 无歧义)。
- **`session` 留在 `core/`;持久化独立成族。** 会话日志是核心产品 API。其存储后端构成一个平行的能力族(`session-persistence/`),与 `llm/``bash/` 对称,而非嵌套在 `core/session/` 下。
- **`agent-loop``core/` 中。** 它是 `agent` seam 唯一的具体实现,但作为 harness 的默认产品循环交付,因此与核心主干同处。插件仍然依赖 `agent` 的词汇,从不依赖 `agent-loop`,所以循环仍可替换。
- **`invariants``ui-stdio` 属于 `support/`,不是产品。** `invariants` 是开发模式的契约检查。`ui-stdio` 从示例中提取出来以便复用和满足覆盖率门禁——它与示例耦合,因此与 `llm-replay`(快照测试的回放适配器)一起放在 `support/` 中。`acp``ui/` 的唯一成员,因为它是真正的产品接口(编辑器驱动的 ACP 桥接),与 readline 演示辅助工具在结构上截然不同
- **产品自动化与面向人类的 UI 是两个独立分组。** `acp` 是位于 `acp/` 下的产品传输层,而命令、审批、交互和展示适配器位于 `ui/` 下。仅开发用的 invariants 与回放基础设施仍留在 `support/`
### 去重包列表
@@ -70,7 +71,7 @@ packages/
- **第三层(每个族下设 `adapters/`/`impls/`)**:否决。统一深度 2 使 workspace glob 保持简洁的 `packages/*/*`,并让一条 `@deepseek-ai/dsh-*` tsconfig 通配符即可解析所有包。
- **将持久化嵌套在 `core/session/` 下**:否决。存储后端构成一个平行的能力族,与 `llm/``bash/` 对称,而会话日志本身属于核心产品 API。
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口`acp``ui/` 的唯一成员,因为编辑器实际驱动它
- **`ui-stdio` 放在 `ui/` 下**:否决。它是与示例耦合的开发支撑,不是产品接口。
## 后果
@@ -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
2026-06-30-event-domain-semantics.md: 7310840088d4ff77ddf83c5c16753b4d46256692
2026-06-30-event-domain-semantics.zh.md: 61d99878ab5e04b74d55d02789c434e0919bd785
2026-06-30-event-domain-semantics.md: 1f3452cce0235718c35d71577d7013f3e647648c
2026-06-30-event-domain-semantics.zh.md: ec2da7786e80fb6a0df9ff338d77a50e7b3ef569
@@ -20,13 +20,13 @@ This vocabulary is the foundation for interception decisions, the durable `hook/
**Three domains, one job each, with a single boundary rule.**
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path.
- **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and replay projections share one path.
- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`).
- **`tools/*` — the tool registry + execution seam.**
**The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit.
**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit).
**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge correlates its in-flight prompt with the exact `session/event` `turn/start`/`turn/end` pair, and other transcript consumers likewise derive boundaries from the durable stream. See [the remove-boundary-mirror-events Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit).
## Consequences
@@ -20,13 +20,13 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)
**三个域,各司其职,以一条边界规则统一。**
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与 `session/load` 回放共享同一路径。
- **`session/*`——持久的、可回放的事实日志。** 拥有 `SessionEventMap`;每条记录仅含 JSON(无活对象)。每次追加触发一次 `session/event` emit,加上 `session/flush` 并行持久性检查点。它同时也是实时 transcript(文本记录)源:想渲染或响应已发生事件的消费方在此订阅,因此实时渲染与回放投影共享同一路径。
- **`agent/*`——运行时实时表面。** 始终携带活的 `Agent`。两种形态:拦截 waterfall(瀑布式事件)(`agent/request``agent/step-result``agent/turn-continuation`)可变更或否决;瞬态 emit`agent/status``agent/error``agent/created`/`agent/disposed``agent/queued`)在持有 `Agent` 的情况下通知。轮次和步骤边界不在此处——它们是持久的会话事件,从 `session/event` 读取;token 流(`assistant/chunk`)和中途 steering(中途引导)(`steering/message`)同理。
- **`tools/*`——工具注册表与执行 seam。**
**边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。
**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`ACP 桥接 `session/event` `turn/end``agent/status` 结算;唯一的轮次镜像消费方(`dsh-ui-stdio`,一个一次性测试 REPL)从 `session/event` 渲染边界,同时保留其实时目标对象用于固定的 `main` 标签。步骤镜像先被移除(它们完全没有消费方);轮次镜像在 ui-stdio 迁移后随之移除,见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。
**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start``agent/turn-end``agent/step-start``agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`ACP 桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。
## 后果
@@ -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
2026-07-02-fs-per-session-cwd.md: aeb17e0a235720bee277f6533468dbdb6cae82dc
2026-07-02-fs-per-session-cwd.zh.md: b9ad4b868c472c8feb9e6f65e42b1658ffdcd2e0
2026-07-02-fs-per-session-cwd.md: d3f54e89e735016a373fa14c60123c681b3e7adf
2026-07-02-fs-per-session-cwd.zh.md: ae732a3e4dacc3d4b800044aad60df3f3ce17cc0
@@ -6,9 +6,9 @@ English | [中文](2026-07-02-fs-per-session-cwd.zh.md)
## Problem
The ACP bridge gives every session its own workspace: `session/new` records the editor's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the per-session cwd Agent Note work in `packages/ui/acp`](../../../../packages/ui/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
The ACP bridge gives every session its own workspace: `session/new` records the automation client's project directory as `SessionHeader.cwd`, and `dsh-tool-bash` defaults each bash call's `workdir` to the calling agent's `session.header.cwd` (see [the ACP package](../../../../packages/acp/acp) and `resolveWorkdir` in `dsh-tool-bash`). So a bash command in session A runs in A's project, and in session B runs in B's — one server process, N workspaces.
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the editor project differed from the server launch directory; snapshots hid the bug by making those paths identical.
Filesystem resolution used one plugin-load cwd while bash used the session project directory. Relative paths therefore disagreed whenever the automation client's project differed from the server launch directory; snapshots hid the bug by making those paths identical.
A valid absolute cwd can itself have two apparent parents: when it contains `symlink/..`, filesystem lookup follows the symlink before applying `..`, while `path.resolve()` erases both components lexically. Resolving sandbox policy lexically while launching bash from the raw cwd granted the unrelated lexical parent, denied writes in the real workspace, and let filesystem tools resolve relative paths into the wrong directory.
@@ -19,7 +19,7 @@ An ordinary symlink cwd exposes the same distinction when the requested relative
Thread the caller's session cwd into path resolution, exactly as `dsh-tool-bash` already does for `workdir`. When either the cwd or the requested path contains a parent segment, resolve the cwd to its native filesystem identity before any lexical join; ordinary cwd spellings stay stable for display when no traversal makes their identity observable. Reuse the resolved sandbox-policy root for mutations and sandboxed bash calls so one call has one workspace identity. The **caller** (the tool) supplies the cwd; the provider does not read a session or agent.
- `FileSystem.resolve` accepts `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>`. `opts.cwd` is the base a RELATIVE `path` resolves against; an absolute `path` ignores it; omitting `opts.cwd` uses the backend's own default. `opts.signal` cancels resolution when the backend performs I/O. The options object keeps both caller-owned resolution controls together without positional growth.
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies none (non-ACP / no-session use, and the single-session stdio demo where `process.cwd()` IS the workspace).
- `dsh-fs-local.resolve` uses `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)`. `config.cwd` stays the default for a caller that supplies no session cwd.
- `dsh-tool-fs`'s `read`/`write`/`edit` derive the session cwd through a shared `sessionCwd(exec, requestedPath)` helper (`exec.agent?.session.header.cwd`, mirroring bash's `resolveWorkdir`) and pass it to `resolve`. The helper uses native realpath semantics when a parent segment in either value could cross a symlink while retaining ordinary spellings otherwise; a sandboxed mutation reuses the complete policy's `workspaceRoot`; a non-agent / headerless caller yields `undefined`, so the backend applies its default.
## Alternatives considered
@@ -32,7 +32,7 @@ The default lives in ONE place — the provider's `config.cwd`. `sessionCwd` ret
## Consequences
- In the ACP demo the fs tools and bash now agree on each session's workspace; an editor can open any project folder and both tool families act on it.
- In the ACP demo the fs tools and bash agree on each session's workspace; an automation client can select any absolute project directory and both tool families act on it.
- A session cwd containing `symlink/..`, or an ordinary symlink cwd paired with a parent-traversing relative path, resolves from the same physical workspace for bash, filesystem tools, and the sandbox grant; the lexical parent receives no grant.
- No change to `FsTarget` identity: `targetKey` is still the realpath of the resolved absolute path, so observed-state keying and symlink identity are unaffected — a correct per-session cwd produces the same key bash targets.
- Backward compatible: every existing `resolve(path)` call (all in tests) keeps working; the new argument is optional.
@@ -6,9 +6,9 @@ Status: implemented
## 问题
ACPAgent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new`编辑器的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [`packages/ui/acp`](../../../../packages/ui/acp) 中的每会话 cwd Agent Note 工作`dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。
ACPAgent Client Protocol)桥接层为每个会话提供独立的工作区:`session/new`自动化客户端的项目目录记录为 `SessionHeader.cwd``dsh-tool-bash` 将每次 bash 调用的 `workdir` 默认设为调用方 agent(智能体)的 `session.header.cwd`(见 [ACP 包](../../../../packages/acp/acp)与 `dsh-tool-bash` 中的 `resolveWorkdir`)。因此会话 A 中的 bash 命令在 A 的项目目录执行,会话 B 中的在 B 的项目目录执行——一个服务器进程,N 个工作区。
文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当编辑器项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
文件系统解析使用的是插件加载时的 cwd,而 bash 使用的是会话的项目目录。因此,当自动化客户端的项目目录与服务器启动目录不同时,相对路径的解析结果就会不一致;快照测试因为让这两个路径相同而掩盖了这个 bug。
一个有效的绝对 cwd 本身可能看起来有两个父目录:当它包含 `symlink/..` 时,文件系统查找会先跟随符号链接再应用 `..`,而 `path.resolve()` 会从词法上抹掉这两个组件。如果用词法解析沙箱策略却从原始 cwd 启动 bash,就会把权限授予无关的词法父目录、拒绝真实工作区内的写入,并让文件系统工具把相对路径解析进错误目录。
@@ -19,7 +19,7 @@ ACPAgent Client Protocol)桥接层为每个会话提供独立的工作区
将调用方的会话 cwd 传入路径解析,与 `dsh-tool-bash``workdir` 的处理方式完全一致。当 cwd 或请求路径任一包含父目录段时,在任何词法 join 之前把 cwd 解析为原生文件系统标识;没有遍历会使标识可观察时,则保留普通 cwd 拼写以供展示。mutation 和沙箱化 bash 调用复用解析后的沙箱策略根目录,使一次调用只有一个工作区标识。**调用方**(即工具)提供 cwd;提供方不读取会话或 agent。
- `FileSystem.resolve` 接受 `resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>``opts.cwd` 是相对 `path` 解析时的基准目录;绝对 `path` 忽略它;省略 `opts.cwd` 则使用后端自身的默认值。后端执行 I/O 时,`opts.signal` 可以取消解析。options 对象把调用方拥有的两个解析控制项放在一起,避免位置参数继续增长。
- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)``config.cwd` 仍作为调用方未提供 cwd 时的默认值(非 ACP/无会话场景,以及 `process.cwd()` 本身就是工作区的单会话 stdio 演示)
- `dsh-fs-local.resolve` 使用 `resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)``config.cwd` 仍作为调用方未提供会话 cwd 时的默认值。
- `dsh-tool-fs``read`/`write`/`edit` 通过共享的 `sessionCwd(exec, requestedPath)` 辅助函数(`exec.agent?.session.header.cwd`,与 bash 的 `resolveWorkdir` 对应)获取会话 cwd,并传给 `resolve`。只要任一值中的父目录段可能跨越符号链接,该辅助函数就使用原生 realpath 语义,否则保留普通拼写;沙箱化 mutation 复用完整策略的 `workspaceRoot`;非 agent/无 header 的调用方得到 `undefined`,后端因此应用其默认值。
## 曾考虑的替代方案
@@ -32,7 +32,7 @@ ACPAgent Client Protocol)桥接层为每个会话提供独立的工作区
## 后果
- 在 ACP 演示中,fs 工具与 bash 现在对每个会话的工作区达成一致;编辑器可以打开任意项目目录,两类工具都在该目录下操作。
- 在 ACP 演示中,fs 工具与 bash 对每个会话的工作区达成一致;自动化客户端可以选择任意绝对项目目录,两类工具都在该目录下操作。
- 对于包含 `symlink/..` 的会话 cwd,或普通符号链接 cwd 搭配含父目录遍历的相对路径,bash、文件系统工具和沙箱授权都会从同一个物理工作区解析;词法父目录不会获得授权。
- `FsTarget` 的标识不变:`targetKey` 仍为解析后绝对路径的 realpath,因此 observed-state 键控与符号链接标识不受影响——正确的每会话 cwd 产生与 bash 目标相同的 key。
- 向后兼容:所有现有的 `resolve(path)` 调用(均在测试中)继续正常工作;新参数是可选的。
@@ -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
2026-07-02-result-time-applied-hunk-diffs.md: fe163484ad56858fabd55dacace1d56814bd93e4
2026-07-02-result-time-applied-hunk-diffs.zh.md: e8283a40d96c031a84e2627117801b3043eb8904
2026-07-02-result-time-applied-hunk-diffs.md: 55e1612aacd9070ede2f0079c73c30975e1bd5cf
2026-07-02-result-time-applied-hunk-diffs.zh.md: 6fe0032a507af2915bdf79a43578082785411479
@@ -6,7 +6,7 @@ English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md)
## Problem
The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gave `dsh-tool-fs` write/edit a `card:'diff'` at CALL time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). An editor renders that as an inline diff, but it is a **context-free** diff — the bare `old_string``new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair.
The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gives `dsh-tool-fs` write/edit a `card:'diff'` at call time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). A UI can render that as an inline diff, but it is a **context-free** diff — the bare `old_string``new_string` with no surrounding lines, and a `replace_all` that touched five scattered sites still renders as one snippet pair.
Driving `claude-agent-acp`'s own ACP bridge shows what a full editor diff looks like: after the mutation applies, it emits a SECOND `tool_call_update` whose diff is the **applied hunk with ±3 context lines** (and one hunk per changed site for `replace_all`), reconstructed from the tool's `structuredPatch`. That result-time hunk is what makes Zed show the change *in place* in the file rather than as a floating snippet. Our tools stopped at the call-time snippet; the completed result carried only the plain "updated successfully" text, no diff.
@@ -29,11 +29,11 @@ This remains the general shape ("a tool projects durable result presentation"),
Per the [capability-seam split](2026-06-13-capability-seams.md), the storage backend returns only **storage facts** and the model-facing tool owns **presentation**:
- `dsh-fs` widens `FsEditOutcome` with `{ before: string; after: string }` and `FsWriteOutcome` with `{ before: string | null; after: string }` (`before: null` ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with **no diff/UI concept** entering the seam.
- `dsh-tool-fs` returns canonical before/after mutation facts and projects contextual hunks as `meta: { diffs: FileDiff[] }`. Successful mutations always complete with a diff card because ACP result content replaces the pending card: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
- `dsh-tool-fs` returns canonical before/after mutation facts and projects contextual hunks as `meta: { diffs: FileDiff[] }`. Successful mutations complete with a diff view: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.
### 3. The bridge renders a `diff` result card
### 3. UI transports render a `diff` result view
`ToolResultView` gains a `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`; the bridge's result-side `switch (view.card)` gets a `diff` arm emitting the `{type:'diff'}` `ToolCallContent` blocks (mirroring the call-side arm). An ACP `tool_call_update.content` REPLACES the call's content in an editor, so the result diff **supersedes** the call-time snippet (and keeps the model-facing result text from clobbering it) — the two-update sequence (call snippet, then result diff) matches `claude-agent-acp` exactly.
`ToolResultView` includes `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`. TUI and JSON-RPC/Web consumers switch on the same tagged view and replace the pending call's context-free snippet with the applied result hunk. The [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) does not carry tool presentation.
## Alternatives considered
@@ -6,7 +6,7 @@ Status: implemented
## 问题
[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时刻提供 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。编辑器将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string``new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
[带标签的 render-intent 联合类型](2026-07-02-tool-render-intent-union.md)为 `dsh-tool-fs` 的 write/edit 在调用时刻提供 `card:'diff'`,纯粹从工具参数推导:write ⇒ `{oldText:null, newText:content}`(整个新文件),edit ⇒ `{oldText:old_string, newText:new_string}`(裸替换片段)。UI 可以将其渲染为行内 diff,但这是一个**无上下文**的 diff:裸的 `old_string``new_string` 没有周围行,而一次触及五个分散位置的 `replace_all` 仍然渲染为一对片段。
在对接 `claude-agent-acp` 自身的 ACPAgent Client Protocol bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 `tool_call_update`,其 diff 是**带 ±3 行上下文的 applied hunk**`replace_all` 的每个变更位置各一个 hunk),由工具的 `structuredPatch` 重建。这个结果时刻的 hunk 正是让 Zed 在文件中*原位*显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本「updated successfully」,没有 diff。
@@ -29,11 +29,11 @@ Status: implemented
按照 [capability-seam 拆分](2026-06-13-capability-seams.md),存储后端只返回**存储事实**,面向模型的工具拥有**展示**:
- `dsh-fs``FsEditOutcome` 扩展为包含 `{ before: string; after: string }`,将 `FsWriteOutcome` 扩展为包含 `{ before: string | null; after: string }``before: null` 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,**不让任何 diff/UI 概念进入 seam**。
- `dsh-tool-fs` 返回规范的变更前/后事实,并将上下文 hunk 投影为 `meta: { diffs: FileDiff[] }`。成功的变更始终以 diff 卡片完成,因为 ACP 结果内容会替换待定卡片:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。
- `dsh-tool-fs` 返回规范的变更前/后事实,并将上下文 hunk 投影为 `meta: { diffs: FileDiff[] }`。成功的变更以 diff 视图完成:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。
### 3. Bridge 渲染 `diff` 结果卡片
### 3. UI 传输层渲染 `diff` 结果视图
`ToolResultView` 新增 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`bridge 结果侧的 `switch (view.card)` 增加 `diff` 分支,发出 `{type:'diff'}``ToolCallContent` 块(与调用侧分支对称)。ACP 的 `tool_call_update.content` 在编辑器中替换调用时的内容,因此结果 diff **取代**调用时刻的片段(并防止面向模型的结果文本覆盖它)——两次更新序列(先调用片段,再结果 diff)与 `claude-agent-acp` 完全一致
`ToolResultView` 包含 `DiffResultView { card:'diff'; title?; diffs: FileDiff[] }`。TUI 与 JSON-RPC/Web 消费方在同一个带标签的视图上做 switch,用 applied 结果 hunk 替换待定调用的无上下文片段。[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)不承载工具展示
## 曾考虑的替代方案
@@ -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
2026-07-02-tool-render-intent-union.md: c7adf8f405000ec7940f82e1e1e2406d86253461
2026-07-02-tool-render-intent-union.zh.md: 01dff0daaacc45f5c85eb2c4380dc78800ce9a81
2026-07-02-tool-render-intent-union.md: 6cfd8921decbe16343f963574edd52173c2f8698
2026-07-02-tool-render-intent-union.zh.md: d0414c5f15995192df898e968d054933f82d2ab4
@@ -4,6 +4,8 @@ Status: implemented
English | [中文](2026-07-02-tool-render-intent-union.zh.md)
> The render-intent union remains current for UI transports; its ACP mapping is superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md).
## Problem
A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy:
@@ -12,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug
- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense.
- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff.
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot replay path).
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers.
## Decision
@@ -39,8 +41,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
### Why a tagged union beats the field-bag
- **Invalid states become unrepresentable.** A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these.
- **The bridge switches instead of stitching.** One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented.
- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'`; the bridge emits an ACP `{type:'diff', path, oldText, newText}` `ToolCallContent` (already in the SDK's `ToolCallContent` union, previously unused by the bridge). This is the affordance the redesign unlocks.
- **Consumers switch instead of stitching.** One arm per card kind produces exactly the view that card needs, rather than reconciling five optional fields whose interactions are undocumented.
- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'` with `{path, oldText, newText}`, allowing capable UIs to render an inline change without tool-name special cases.
### Producer mapping
@@ -56,10 +58,6 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string``new_string`.
## Relative-path display titles
`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names.
## Alternatives considered
- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
@@ -78,5 +76,4 @@ A new render intent is a compile-breaking change at the bridge switch — delibe
- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union.
- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card.
- Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes).
- The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card.
- Folds `ToolTerminal` into the tagged `terminal` views used by current UI transports.
@@ -4,6 +4,8 @@ Status: implemented
[English](2026-07-02-tool-render-intent-union.md) | 中文
> render-intent 联合类型对 UI 传输层仍然有效;其 ACP 映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
## 问题
工具通过 `ToolDefinition` 上的两个回调 `presentCall`/`presentResult` 声明其调用在 UI(编辑器的工具调用卡片)中如何渲染,返回 `ToolCallPresentation` / `ToolResultPresentation`,并带有一个可选的 `ToolTerminal` 子结构。这些类型在增量演进中变成了一个**可选字段的集合**:调用侧有 `title``kind``rawInput``content``locations``terminal`;结果侧有 `title``content``terminal``ToolTerminal` 上有 `cwd`/`output`/`exitCode`/`signal`。职责划分模糊不清:
@@ -12,7 +14,7 @@ Status: implemented
- 哪些组合是*合法的*没有文档说明:一个设置了 `content``terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。
- 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)*`ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件现已满足:两个生产者族(`dsh-tool-bash``dsh-tool-fs`)和两个消费方(ACP bridge 实时路径 + 快照回放路径)
`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足
## 决策
@@ -39,8 +41,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
### 为什么带标签联合类型优于字段集合
- **无效状态变得不可表达。** generic 卡片不能携带终端输出;terminal 卡片不能携带 diff。旧的字段集合允许所有这些组合。
- **bridge 分发而非拼接。** 每种卡片一个分支,各自精确产出该卡片所需的协议格式(wire format,而非调和五个交互关系未文档化的可选字段。
- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明 `card:'diff'`bridge 输出 ACP `{type:'diff', path, oldText, newText}` 的 `ToolCallContent`(已存在于 SDK 的 `ToolCallContent` 联合类型中,此前 bridge 未使用)。这正是本次重设计解锁的能力
- **消费方分发而非拼接。** 每种卡片一个分支,精确产出该卡片所需的视图,而非调和五个交互关系未文档化的可选字段。
- **`diff` 成为一等意图。** `dsh-tool-fs` 的 write/edit 声明带 `{path, oldText, newText}` 的 `card:'diff'`,让有能力的 UI 无需针对工具名做特殊处理即可渲染行内变更
### 生产者映射
@@ -56,10 +58,6 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
`presentCall`/`presentResult` 仍然是 `args``presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string``new_string`
## 相对路径显示标题
`claude-agent-acp` 将文件卡片标题中的路径相对于会话 cwd 做缩短处理(`toDisplayPath`)——显示 `Read src/foo.ts` 而非 `/abs/proj/src/foo.ts`——同时保持 `locations[]`/`diff.path` 为**原始路径**(编辑器打开真实路径)。我们的 `presentCall` 是纯函数/仅依赖 args,无法访问会话 cwd,因此这一相对化处理发生在 **bridge**bridge 已经将会话 cwd 传入工具调用渲染逻辑(与它用于解析 terminal 卡片标题的 cwd 相同)。bridge 仅对标题做相对化,方式是对已知的 `locations[0].path`/`diffs[0].path` 子串做精确的结构化替换——对所有文件卡片类型通用,从不针对工具名做特殊处理。
## 曾考虑的替代方案
- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。
@@ -78,5 +76,4 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string
- 取代[折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。
- 被[结果时已应用 hunk 差异](2026-07-02-result-time-applied-hunk-diffs.md)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。
-`ToolTerminal` 折入 [ACP terminal 与工具调用渲染](../feature/2026-06-18-acp-terminal-and-tool-rendering.md)所描述的 `terminal` view`_meta` terminal 卡片约定和能力门控不变;仅 harness 侧的展示类型改变)
- ACP SDK 的 `Diff` / `ToolCallContent` 类型支撑新的 `diff` 卡片。
-`ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图
@@ -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
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 43ba5708d1216c37a7ad7e2904df7d2a6baf016d
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b33ff870d745584d2988bb6a7eb1a31e56ec3da
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 39cfb2999dea7767a18702ad7d160c9e88d7bf20
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: e1a21c40647e1418d4afd02c0bc6b44ef0d4a8cf
@@ -25,7 +25,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t
### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
@@ -25,7 +25,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)vercel/pkg 归档后
### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两包
确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()``boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0SIGINT → 130)。
@@ -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
2026-07-12-agent-scope-runtime-design.md: cf42bbacbfb9d2fcbd72aafba5ea02ddf83e1ce5
2026-07-12-agent-scope-runtime-design.zh.md: d0d599664290dc7dc4debe1ccf8f419a40912ba1
2026-07-12-agent-scope-runtime-design.md: 232fc02d66411b5ee8a21943795a3be4713bf238
2026-07-12-agent-scope-runtime-design.zh.md: 39d558f8cde0183a3590d268aca36ea85e5f5c63
@@ -288,9 +288,9 @@ An ACP provider crosses a real process and wire boundary, so it retains validati
Start resolves only after `initialize` and `newSession` succeed. Abort, spawn failure, RPC failure, or invalid startup response reaps the process before rejection. After readiness, result maps the ACP prompt outcome and streamed output; dispose requests cancellation, closes the connection, and awaits process exit through one memoized path.
## Workflows and ACP UI: retain only independent async facts
## Workflows and ACP processes: retain only independent async facts
Worker and editor bridges need more state than same-process registries because messages, process death, and rendering can settle independently. Their state is organized around those real facts rather than duplicate cancellation protocols.
Worker and child-process bridges need more state than same-process registries because messages, process death, and cleanup can settle independently. Their state is organized around those real facts rather than duplicate cancellation protocols.
### Workflow children are pending starts or published records
@@ -306,11 +306,11 @@ The workflow result records the first accepted terminal outcome according to the
Public disposal claims its memoized promise before invoking callbacks. Worker death closes admission before processing any queued late child request, synthesizes missing lifecycle ends, and starts child/process cleanup without rewriting an outcome already claimed.
### ACP prompt settlement does not depend on rendering success
### ACP prompt settlement does not depend on update delivery
The ACP UI correlates a prompt with its observed turn directly. It does not scan from a `logWatermark` or use session status as a second reconciliation oracle.
The [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) correlates one in-flight prompt with its observed user-message turn directly. It does not scan from a log watermark or use session status as a second reconciliation oracle.
Prompt handling settles correlation in a `finally` around transcript rendering. A rendering failure can fail presentation, but it cannot skip prompt settlement or leave the session permanently in flight. Concurrent loads of the same persisted caller-supplied session ID remain excluded because that is a real persistence identity race, not a UUID collision concern.
The session-event listener settles correlation from the matching `turn/end` even when a committed-message update cannot reach the client. Update delivery therefore cannot leave the session permanently in flight. ACP creates server-assigned fresh session ids and owns every resulting agent handle until connection teardown.
## Correctness enforcement
@@ -288,9 +288,9 @@ ACP 提供方跨越真实的进程和协议格式边界,因此它保留验证
Start 仅在 `initialize``newSession` 成功后才 resolve。Abort、spawn 失败、RPC 失败或无效启动响应在拒绝前回收进程。就绪后,result 映射 ACP 提示词结果和流式输出;dispose 请求取消、关闭连接并通过一条记忆化路径等待进程退出。
## 工作流与 ACP UI:仅保留独立的异步事实
## 工作流与 ACP 进程:仅保留独立的异步事实
Worker 和编辑器桥接比同进程注册表需要更多状态,因为消息、进程死亡和渲染可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。
Worker 和子进程桥接比同进程注册表需要更多状态,因为消息、进程死亡和清理可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。
### 工作流子级是待定 start 或已发布记录
@@ -306,11 +306,11 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲
公开 dispose 在调用回调之前取得其记忆化 promise 的所有权。Worker 死亡在处理任何排队的迟到子级请求之前关闭准入,合成缺失的生命周期结束,并启动子级/进程清理而不重写已声明的结果。
### ACP 提示词结算不依赖渲染成功
### ACP 提示词结算不依赖更新投递
ACP UI 直接将提示词与其观察到的轮次关联。它不从 `logWatermark` 扫描,也不使用会话状态作为第二个调和预言机。
[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。
提示词处理在 transcript(文本记录)渲染的 `finally` 结算关联。渲染失败可以导致展示失败,但不能跳过提示词结算或让会话永久处于进行中状态。对同一持久化的调用方提供的会话 ID 的并发加载仍被排除,因为那是真实的持久化标识竞争,而非 UUID 碰撞问题
即使已提交消息的更新无法送达客户端,会话事件监听器也会从匹配的 `turn/end` 结算关联。因此更新投递不能让会话永久处于进行中状态。ACP 创建由服务器分配 id 的全新会话,并拥有由此产生的每个 agent 句柄,直到连接拆除
## 正确性强制
@@ -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
2026-07-12-scoped-layers-store.md: b850b6bcbb22401b386b4458b6d5c65a160c85cd
2026-07-12-scoped-layers-store.zh.md: 8bfc0a0e8ec1e3de624ff8d9e48b7517833fc025
2026-07-12-scoped-layers-store.md: c5186d1652bca617eed62ec02937f2d055ea727c
2026-07-12-scoped-layers-store.zh.md: 3183811be553428ebcd8f59f15989c44d458b477
@@ -123,4 +123,4 @@ All seven facades keep validation and diagnostics in their owning registry and c
- `dsh-scope` unit tests cover global construction, lazy scoped construction, non-creating reads, named merge order and shadowing, aggregate reclamation, factory and action failure cleanup, notification ordering and rollback, `notify: false`, effect labels, exact disposer identity, idempotent teardown, caller-owned duplicate errors, independent anonymous duplicates, live iterators, and drained-generation detachment.
- Focused tool, system-prompt, and command suites cover restrictions, reserved transport handling, known/restrictable-name agreement, guard re-entrancy and self-replacement, validation order, exact diagnostics, section shadow-before-evaluate, provider snapshot membership, variable re-entrancy and self-replacement, contained command observers, frozen and sorted views, direct execution, and lifecycle disposal.
- The scoped core-data type-equivalence check ties `ScopeLayer` documentation to its source declaration. Repository documentation, module-graph, build, hygiene, coverage, and built-artifact gates exercise the root export and package boundary.
- Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas, prompt assembly, and human commands. The implementation does not update any expected transcript.
- Existing ACP, headless, and TUI keyless snapshots remain the regression boundary for tool schemas and prompt assembly; TUI coverage owns human commands. The implementation does not update any expected transcript.
@@ -123,4 +123,4 @@ export class AnonymousEntries<V> {
- `dsh-scope` 单元测试覆盖全局构造、专属层延迟构造、非创建式读取、命名合并顺序与遮蔽、聚合回收、工厂与 action 失败清理、通知顺序与回滚、`notify: false`、effect 标签、原始 disposer 身份、幂等拆除、调用方提供的重名错误、相同匿名值的独立登记、活迭代器,以及表清空后的 generation 脱离。
- 工具、系统提示词和命令专项测试套件覆盖 restriction、保留传输处理、已知名称与可限制名称的一致性、guard 重入与自我替换、校验顺序、精确诊断、section 先遮蔽再求值、提供方快照成员关系、variable 重入与自我替换、隔离失败的命令观察者、冻结且有序的视图、直接执行和生命周期销毁。
- 作用域核心数据的类型等价性检查将 `ScopeLayer` 文档与其源声明绑定。仓库级的文档、模块图、构建、hygiene、覆盖率与构建产物门禁会覆盖包根导出与包边界。
- 现有 ACPAgent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema提示词组装和人类命令的回归边界。实现不会更新任何预期 transcript(文本记录)。
- 现有 ACPAgent Client Protocol)、headless 和 TUI 无密钥快照继续作为工具 schema提示词组装的回归边界;人类命令由 TUI 覆盖。实现不会更新任何预期 transcript(文本记录)。
@@ -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
2026-07-15-llm-model-catalog-and-acp-selection.md: 6cc8afc6c7431fbf3eb29fc358b432db4f72b529
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 1cce7a58d0ec83dc01feaf72ccb61d294a78ddd5
2026-07-15-llm-model-catalog-and-acp-selection.md: adbd8671f0ea5cd2e0c049c32616453882329396
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: dfcb581e43149281cd18b28f1411ea98944983ad
@@ -4,6 +4,8 @@ Status: implemented
English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md)
> The catalog decision remains current. Per-session ACP model selection is superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md).
## Problem
Provider-routed adapters let every request choose `provider + model`, but `LlmService` exposed only routing and streaming. A UI could not discover which providers were registered or which models an adapter was prepared to recommend. ACP clients therefore received no `model` session config option, so Zed, JetBrains, and VS Code integrations had no model list even though the request seam already supported runtime switching.
@@ -24,21 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` and `deepseek-v4-pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
### ACP session config option
### Per-session selection in the front door
The ACP bridge advertises one select with `id: model` and `category: model` in `session/new` and `session/load` when the session has a complete target whose provider is registered. Each opaque option value encodes the full provider/model pair. Models are grouped by provider when multiple non-empty provider groups exist; a single group is flattened for clients that render simple selects better.
A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes.
The session's current target is added to the displayed options when its adapter omits it. This preserves custom DeepSeek and private-endpoint models while keeping the adapter catalog advisory. A target with an unregistered provider is not advertised, and a model-less agent remains available to another `agent/request` supplier.
`session/set_config_option` accepts only values from the current catalog snapshot and updates a target reference owned by that ACP session. No global `LlmService` or `AgentOptions` state changes, so concurrent sessions may select different providers and models. The existing permission select remains independent, and every response returns the complete refreshed option state.
The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface.
### Prompt/request consistency and durability
Agent setup installs scoped `system-prompt/assemble` and `agent/request` listeners. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
`installAgentLlmTarget` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned target. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
The request header remains the durable source of truth. When a selected target is actually used, the existing full `request/header` snapshot records it. `session/load` initializes the ACP selection from the folded last request header before falling back to bridge config. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
ACP's experimental `providers/*` capability is not used. That draft surface configures provider base URLs, protocols, and headers, including secrets; it does not enumerate models and would give the UI authority to rewrite deployment-owned adapter configuration.
The request header remains the durable source of truth. When a selected target is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
## Alternatives considered
@@ -46,21 +44,19 @@ ACP's experimental `providers/*` capability is not used. That draft surface conf
**Make catalogs mandatory whitelists.** This conflicts with the hand-written adapter's arbitrary model pass-through and private deployments. The selected adapter already owns authoritative request validation.
**Store selection in `AgentOptions` or `LlmService`.** Those are creation-wide or deployment-wide objects. Mutating them would couple concurrent ACP sessions and bypass the logged `agent/request` replacement path.
**Store selection in `AgentOptions` or `LlmService`.** Those are creation-wide or deployment-wide objects. Mutating them would couple concurrent sessions and bypass the logged `agent/request` replacement path.
**Persist a new model-selection session event immediately.** An unused UI selection has not affected a model request. Recording the existing request header when the target is consumed preserves the model-visible-if-and-only-if-logged rule without adding a second source of truth.
**Use ACP `providers/*`.** That unstable API changes endpoint and authentication configuration rather than selecting a model for one session, and its lifecycle and secret-handling semantics do not match this feature.
## Consequences
- Any adapter can expose a dynamic model list without leaking provider-library types into the core seam.
- Catalog consumers must treat absence as “not advertised,” never “invalid request.”
- pi-ai-backed ACP deployments automatically inherit the installed pi-ai provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support.
- ACP clients receive a standard stable model config option, with provider-aware values and per-session isolation.
- pi-ai adapters expose their installed provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support.
- Human-facing catalog consumers own their selection interaction. ACP uses its fixed deployment target and does not widen the protocol with model discovery.
- Request headers remain compatible with the provider-routed session shape; no new JSONL event or format version is required.
- A catalog read can be asynchronous. ACP reads a detached snapshot before creating or resuming an agent, so discovery failure cannot leave a partially published session.
- A catalog read can be asynchronous, and every caller receives detached values.
## Testing
Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, ACP provider grouping, custom-current insertion, invalid values, provider/model request routing, prompt-variable alignment, concurrent-session isolation, model-less fallback, and load restoration from the request header. The existing ACP transport suites verify that the additional config option does not change prompt, cancellation, replay, approval, or tool-rendering behavior.
Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP transport tests validate fixed provider/model forwarding independently of catalog discovery; the TUI suite covers selector interaction and header-based restoration.
@@ -4,6 +4,8 @@ Status: implemented
[English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文
> 目录决策仍然有效。ACP 会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。
## 问题
基于提供方路由的适配器允许每次请求选择 `provider + model`,但 `LlmService` 只暴露路由和流式调用。UI 无法发现已注册的提供方,也无法知道适配器愿意推荐哪些模型。因此,ACP 客户端收不到 `model` 会话配置项;即使请求接缝已经支持运行时切换,Zed、JetBrains 和 VS Code 集成仍没有模型列表。
@@ -14,9 +16,9 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
## 决策
### 提供方中立的建议性发现
### 提供方无关的建议性发现
`LlmAdapter` 增加 `providerInfo(provider)` 与异步 `listModels(provider)` 方法。其提供方中立结果分别为 `LlmProviderInfo { id, name }``LlmModelInfo { provider, id, name, description? }`。默认实现以路由名称作为提供方名称,并且不展示模型,从而保持现有适配器行为。
`LlmAdapter` 增加 `providerInfo(provider)` 与异步 `listModels(provider)` 方法。其提供方无关结果分别为 `LlmProviderInfo { id, name }``LlmModelInfo { provider, id, name, description? }`。默认实现以路由名称作为提供方名称,并且不展示模型,从而保持现有适配器行为。
`LlmService.listProviders()` 按注册顺序返回分离后的元数据。`LlmService.listModels(provider)` 委托给路由所有者,校验非空 ID 和名称,并在提供方不匹配或模型 ID 重复时以 `INVALID_CATALOG` 失败,最后返回分离后的值。未知提供方仍以 `NO_ADAPTER` 失败。提供方元数据在 `registerAdapter()` 期间进行原子校验,错误展示记录不会留下部分注册。
@@ -24,43 +26,37 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含 `deepseek-v4-flash``deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
### ACP 会话配置项
### 前门内的会话级选择
当会话具有完整目标且目标提供方已注册时,ACP bridge 会在 `session/new``session/load` 中展示一个 `id: model``category: model` 的选择项。每个不透明选项值都编码完整的提供方/模型字段组合。存在多个非空提供方分组时按提供方分组;只有一个分组时将其展开,以便对简单选择器支持更好的客户端展示
选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService``AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下
如果适配器目录未包含会话当前目标,该目标仍会加入展示选项。这能保留自定义 DeepSeek 与私有端点模型,同时维持目录的建议性。提供方未注册的目标不会展示;缺少模型的 agent 仍可由其他 `agent/request` 提供者补齐
ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口
`session/set_config_option` 只接受当前目录快照中的值,并更新该 ACP 会话独占的目标引用。它不会修改全局 `LlmService``AgentOptions` 状态,因此并发会话可以选择不同的提供方和模型。现有权限选择项保持独立,每次响应都返回完整的刷新后配置项状态。
### Prompt/请求一致性与持久化
### Prompt/请求一致性与持久化
`installAgentLlmTarget`(位于 `dsh-agent`)为前门拥有的目标安装 agent 作用域的 `system-prompt/assemble``agent/request` 监听器。Prompt 组装在每个 step 对所选组合做一次快照,在下游 prompt 监听器之后覆写组装出的 `provider``model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个 step 生效,而不会让 prompt 文本与路由分裂。其他调用配置字段保持不变。
Agent setup 会安装作用域内的 `system-prompt/assemble``agent/request` 监听器。Prompt 组装为每个 step 只快照一次选中的字段组合,在下游 prompt 监听器完成后覆盖组装结果中的 `provider``model` 变量;请求监听器则在下游请求监听器完成后应用同一个快照。因此,异步组装期间发生的选择会从下一个 step 生效,不会导致 prompt 文本与路由分裂。其他调用配置字段保持不变
请求头仍是持久化事实来源。当选中目标被实际使用时,现有的完整 `request/header` 快照会记录它。`session/load` 先从折叠后的最后请求头初始化 ACP 选择,再回退到 bridge 配置。一个从未被请求使用的选择只保留在内存中,因为它从未成为模型可见状态。
本功能不使用 ACP 的实验性 `providers/*` 能力。该草案接口配置提供方 base URL、协议和 headers,其中可能包含密钥;它不枚举模型,并且会赋予 UI 改写部署所有的适配器配置的权力。
请求头仍是持久化的事实来源。当所选目标真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态
## 考虑过的替代方案
**只返回模型字符串。** 模型值会丢失提供方路由两个提供方暴露相同 ID 时立刻产生歧义。
**只返回模型字符串。** 只有模型值会丢失提供方路由,一旦两个提供方暴露相同 ID 就会产生歧义。
**将目录设为强制白名单。** 这与手写适配器的任意模型透传和私有部署冲突。请求的权威校验本就属于被选中的适配器。
**选择存 `AgentOptions` 或 `LlmService`。** 这些对象分别面向创建过程或整个部署。修改它们会耦合并发 ACP 会话,并绕开带日志归因`agent/request` 替换路径。
**选择存 `AgentOptions` 或 `LlmService`。** 它们是创建级或部署级对象。改动它们会把并发会话耦合在一起,并绕过有日志记录`agent/request` 替换路径。
**立即写入新的模型选择会话事件。** 未使用的 UI 选择没有影响模型请求。目标被消费时记录现有请求头,既满足“模型可见当且仅当已记录”的规则,不会引入第二个事实来源。
**使用 ACP `providers/*`。** 该不稳定 API 用于修改端点与认证配置,而不是为单个会话选择模型;其生命周期和密钥处理语义都不适合本功能。
**立即持久化一个新的模型选择会话事件。**使用的 UI 选择尚未影响任何模型请求。目标被消费时记录现有请求头,既保持“模型可见当且仅当有日志”的规则,不会引入第二个事实来源。
## 结果
- 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到核心接缝。
- 目录消费者必须把缺失理解为“未展示”,而不是“请求无效”。
- 基于 pi-ai 的 ACP 部署会自动继承已安装的 pi-ai 提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留任意模型能力
- ACP 客户端会收到稳定标准的模型配置项,其中的值保留提供方信息,并按会话隔离
- 请求头继续使用基于提供方路由的会话结构;不需要增加 JSONL 事件或格式版本。
- 目录读取可以是异步的。ACP 在创建或恢复 agent 前读取分离后的快照,因此发现失败不会留下部分发布的会话
- pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留任意模型的支持
- 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围
- 请求头基于提供方路由的会话形态保持兼容;不需要新的 JSONL 事件或格式版本。
- 目录读取可以是异步的,且每个调用方都会收到分离后的值
## 测试
单元测试覆盖目录分离与错误元数据、pi-ai 和 DeepSeek 目录投影、ACP 提供方分组、自定义当前模型补入、无效值、提供方/模型请求路由prompt 变量一致性、并发会话隔离、无模型回退,以及从请求头恢复选择。现有 ACP 传输测试验证新增配置项不会改变 prompt、取消、回放、审批或工具展示行为
单元测试覆盖目录分离与错误元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及 prompt 变量对齐;按 agent 的隔离来自监听器安装在 agent 作用域上下文这一事实。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复
@@ -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
2026-07-15-lsp-capability-seam.md: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708
2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f
2026-07-15-lsp-capability-seam.md: d96b3a9c5139c1455a51f4fff793293d7b5a11c0
2026-07-15-lsp-capability-seam.zh.md: 54dd32e46dded5722dda910e9138879d3f99de07
@@ -18,7 +18,7 @@ Add LSP as a three-package capability seam with one read-only model tool and one
1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors.
2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping.
3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation.
3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and transport-neutral UI presentation.
`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays.
@@ -100,7 +100,7 @@ The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; a
Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100` and reports omitted items; `maxResultChars` defaults to `16_000` and bounds every complete rendered result, including its truncation metadata. Empty locations and `null` hover are successful no-result responses; missing or malformed server payloads fail with structured `LSP_MALFORMED_RESPONSE` errors.
ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure.
The transport-neutral presenter uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure.
## Timeout ownership
@@ -174,7 +174,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
## Testing
- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication.
- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation.
- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and UI presentation.
- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors.
- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`.
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection.
@@ -182,7 +182,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal.
- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.
- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping.
- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup.
- Snapshots cover model-visible schema, prompt, results, and omissions; a built-artifact smoke test covers framing and cleanup.
- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
## Consequences
@@ -18,7 +18,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程
1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。
2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。
3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACPAgent Client Protocol展示。
3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及与传输方式无关的 UI 展示。
`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。
@@ -100,7 +100,7 @@ interface LspToolInput {
位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,并报告省略的条目;`maxResultChars` 默认值为 `16_000`,并限制每个完整渲染结果,其中包括截断元数据。空位置与 `null` hover 是成功的无结果响应;服务器载荷缺失或格式错误时,以结构化 `LSP_MALFORMED_RESPONSE` 错误失败。
ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }``title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。
与传输方式无关的展示器使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }``title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。
## 超时归属
@@ -174,7 +174,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
## 测试
- 包测试固定三个包的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 UI 展示。
- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。
- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink``hover` 归一化,以及 `findReferences``references.includeDeclaration` 的映射。
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
@@ -182,7 +182,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。
- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`
- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。
- 快照覆盖模型可见 schema、提示词、结果省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。
- 快照覆盖模型可见 schema、提示词、结果省略提示;构建产物冒烟测试覆盖分帧与清理。
- 包与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` 包组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
## 影响
@@ -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
2026-07-22-tui-interactive-extension-service.md: 82e7c751b6e5b7500f9f7d7004fda8b905dccabb
2026-07-22-tui-interactive-extension-service.zh.md: d7340e3f5dcf45e95b2d6e15ce3fc33726a555ae
2026-07-22-tui-interactive-extension-service.md: 86cb39748358882d26766467d08f4f43510c1cc2
2026-07-22-tui-interactive-extension-service.zh.md: d53f526a07b20fcff7086a1f501558d23e7eea8a
@@ -28,7 +28,7 @@ Manager tests pin FIFO admission, cancellation, repeated close, shutdown outcome
**Expose pi-tui objects directly.** This gives plugins maximum freedom but makes private focus, rendering, and teardown state a public compatibility contract. It also cannot arbitrate independently loaded overlays.
**Put interactive callbacks on command definitions.** Commands are shared by TUI and ACP and remain useful without a terminal. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation.
**Put interactive callbacks on command definitions.** Commands remain transport-neutral domain entries even though TUI is their only shipped consumer. Adding terminal state to `ctx.commands` would couple discovery and dispatch to one presentation implementation.
**Create a complete TUI slot and action framework at once.** Actions, editor replacement, transcript renderers, status regions, and completion providers have different composition and conflict rules. Shipping them behind one broad API would freeze those rules before a concrete consumer proves them.
@@ -28,7 +28,7 @@ Cordis 插件可以通过 `ctx.commands` 注册用户命令,但需要终端交
**直接暴露 pi-tui 对象。** 这会赋予插件最大的自由度,却会把私有的焦点、渲染与拆卸状态变成公开兼容性契约,也无法在独立加载的浮层之间进行仲裁。
**在命令定义中加入交互回调。** 命令由 TUI 与 ACP 共享,即使没有终端也仍然有用。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。
**在命令定义中加入交互回调。** 命令仍是传输无关的领域条目,尽管 TUI 是唯一已交付的消费方。向 `ctx.commands` 添加终端状态,会让发现与分派流程耦合到某一种呈现实现。
**一次性建立完整的 TUI slot 与 action 框架。** action、编辑器替换、transcript 渲染器、状态区域和补全提供方具有不同的组合规则与冲突规则。在具体消费方验证这些规则之前就将其纳入一个宽泛 API,会过早固化这些规则。
@@ -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
2026-07-23-client-plugin-loading-model.md: 58651fd258a6b2929c58bb6f93b44adb6e8e1818
2026-07-23-client-plugin-loading-model.zh.md: f60b06c7bfaa9c70170082ac4384ba2bd899676e
2026-07-23-client-plugin-loading-model.md: 3513e026785fc366455bb32bf788a3a098275bb1
2026-07-23-client-plugin-loading-model.zh.md: f31a1b076a5d93db44c67463730b38283d44ff7f
@@ -56,11 +56,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
**Host side — compose the graph.**
1. The composing app (`apps/cli`) mounts the roster as in-memory Loader entries via `mountWebPlugins`. The roster is one flat list of the plugin packages, plus the `client-hmr` row under `--dev`. A roster package that fails to import throws loud at mount.
2. The registry (`createHostWebPluginRegistry`) scans the mounted entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — load-time fail loud.
3. The registry rescans on cordis `internal/plugin`, microtask-debounced; a rescan failure keeps serving the previous graph. Each bundle's content is hashed into its `rev` (cache busting + HMR diff anchor), and the row set into `graph.rev`. Every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are a wire contract dual-held on both sides, because the webserver keeps zero workspace dependencies.
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the settle/sweep so the fail-loud triple covers it. A roster row that fails to import is caught by the boot's `assertEntriesLoaded`.
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses a declared plugin without a built `./client` bundle, and any malformed declaration field — activation-time fail loud (a FAILED fiber the sweep reports).
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is fetch-served: `/plugins/<id>/client.js?rev=…`. The graph types are single-sourced in the modules package's `./impl` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
Why is the roster a hand-written list and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call. The roster lives in `apps/cli/web.ts` rather than cordis.yml only because `dsh web`'s host is a hand-assembled `bootHost` with no Loader config tree yet.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch is fetch + execute, which registers factories only. A single row's prefetch failure is swallowed here: phase two's import retries the fetch and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
@@ -74,9 +74,9 @@ Why is the roster a hand-written list and not a scan? Because which plugins comp
### Hot reload: one driver plugin, self-watched bundles
Whether hot reload is active is a composition decision: dev graphs include the `client-hmr` row (a normal plugin package) and turn on bundle watching; prod graphs do neither.
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
How does a rebuilt bundle become a reload signal? The webserver observes it itself — no builder tells it. The registry scan already holds every plugin's bundle path (`clientPath`), so in dev mode the registry stat-polls each scanned bundle file with `fs.watchFile`. Polling is by design: inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`. On a mtime/size change the registry re-hashes that row (`rebuilt(id)`); when the `rev` actually changed, it broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Watch set membership follows the table: rescans add watches for new rows and drop them for vanished ones, dispose drops all. The poll interval is a validated config field (default 500ms), not a constant. Rebuilding the bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains as the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read of a half-written bundle self-heals: the stats keep changing while the write completes, so the next poll tick re-hashes again and broadcasts the final rev.
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
On the browser side, the driver reloads one plugin per frame, serialized:
@@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land.
Roster endgame: when `dsh web` moves to config-tree boot, the roster lands in cordis.yml — client plugin packages become ordinary config-tree entry rows, `mountWebPlugins` and the `CLIENT_PACKAGES` constant disappear, and recomposing a deployment means swapping the yml/overlay. The registry needs zero changes for that move, since its `internal/plugin` subscription already discovers whatever entries the tree mounts.
Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
## Alternatives considered
@@ -56,11 +56,11 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
**host 侧——组合这张图。**
1. 负责组合的 app`apps/cli``mountWebPlugins` 把名册挂载为内存中的 Loader entry。名册是插件包的一张平铺清单,`--dev` 下外加 `client-hmr`。名册 import 失败的包在挂载时大声抛错
2. 注册表(`createHostWebPluginRegistry`)扫描已挂载 entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——装载期大声失败。
3. 注册表在 cordis `internal/plugin` 上重扫,微任务去抖;重扫失败则继续供给上一张图。每个 bundle 的内容哈希`rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型是两侧各持一份的 wire 契约,因为 webserver 保持零 workspace 依赖
1. 负责组合的 app`apps/cli`把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 settle/sweep 之前追加 `client-hmr` 行,使 fail-loud 三件套一并覆盖它。名册 import 失败由 boot 的 `assertEntriesLoaded` 捕获
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__``{ rev, entries: [{ id, url, rev, inject?, immediately? }] }``inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它拒绝声明了插件却没有已构建 `./client` bundle 的包,也拒绝任何畸形的声明字段——激活期大声失败FAILED fiber,由 sweep 上报)
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希`rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`每一行都经 fetch 供给:`/plugins/<id>/client.js?rev=…`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)
为什么名册是手写清单而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定。名册住在 `apps/cli/web.ts` 而非 cordis.yml,只是因为 `dsh web` 的 host 还是一个手工装配的 `bootHost`,没有 Loader 配置树
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西
**第一层——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即 fetch + 执行,只登记工厂。单行预取失败在这里被吞下:第二层 import 时会重试 fetch 并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
@@ -74,9 +74,9 @@ vendored Loader 经其 `internal` seam 消费模块系统——唯一调用点
### 热重载:一个驱动插件,自行监视的 bundle
热重载是否启用是一项组合决策:dev 图包含 `client-hmr` 行(一个常规的插件包)并开启 bundle 监视;prod 图两者皆无。
热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE 通道;prod 组合不挂载,两者皆无。
重建好的 bundle 怎么变成重载信号?webserver 自己观察——没有构建器来通知它。注册表扫描本就握有每个插件的 bundle 路径(`clientPath`),因此 dev 模式下注册表用 `fs.watchFile` 对每个已扫描的 bundle 文件做 stat 轮询。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因。mtime/size 一变,注册表就重哈希该行(`rebuilt(id)`;当 `rev` 真的变了,才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。监视集合的成员随表走:重扫为新行添加监视、为消失的行撤下监视,dispose(资源释放)撤掉全部。轮询间隔是一个经校验的配置字段(默认 500ms),不是常量。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSEServer-Sent Events)通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
浏览器侧,驱动插件每帧重载一个插件,串行执行:
@@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。
名册的终局:当 `dsh web` 迁到配置树 boot,名册落进 cordis.yml——client 插件包变成普通的配置树 entry 行`mountWebPlugins``CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。注册表为这次迁移零改动,因为它的 `internal/plugin` 订阅本就发现配置树挂载的任何 entry
名册的终局2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml``mountWebPlugins``CLIENT_PACKAGES` 常量消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半
## Alternatives considered
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-web-config-tree-boot-and-transport-layering.md: 9e93b828d5f11060aa476396f6981320c33485a5
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 996a5705bd5d00a2163a146ef8210247f512e6fa
@@ -0,0 +1,42 @@
# Agent Note: dsh web config-tree boot and the web transport layering
Status: implemented
English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
> Scope: how `dsh web` composes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the browser-side loading chain this composition feeds.
## Problem
`dsh web` was the only hand-assembled surface left: `bootHost` mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a `web.ts` constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the `__DSH_BOOT__` graph, owned the SSE channel, and hard-coded the `/api/*` prefix; the dev bundle watch lived inside the prod registry behind a `watch?` flag with no lifecycle owner; the graph registry rescanned everything on every `internal/plugin` emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded `$DSH_HOME/.env`, so `DSH_HOME=… dsh web` could not find an API key living there.
## Decision
**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the ten `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout).
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 1025% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
**Config sources have one declaration place each.** yml static values are engineering defaults; the profile json (`./.dsh-tmp-profile/config.json`, read-only, never created, cwd-anchored until the `$DSH_HOME` migration) is user config mapped through a static `PROFILE_MAPPINGS` table onto target rows (`provider`/`model` → the `api-gateway` row, `persistenceRoot` → the jsonl row); CLI flags map onto the `webserver` row with a field set disjoint from the json's; env values enter through yml `!!js` expressions, never through the mapping table. Patches replace a row's config wholesale, so the entry class re-reads the yml row's static values (bypass parse) and merges overrides on top. An unmapped json key fails loud. The resolved frontend `distIndex` rides the same patch channel — an assembly fact, not user config.
**The transport splits five ways.** `dsh-host-apiproxy` upgraded to the gateway plugin (`api-gateway` row): default-exports `ApiProxyService`, config `{provider, model}`, provides `ctx.apiProxy`, transport-agnostic and registers no routes — `createApiProxy` moved here from runtime (dependency direction allows it; runtime keeps `bootHost`/`startHost` for headless). `dsh-host-webserver` shrank to a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, per-request failures answer 400 and log without exiting, and knows no harness concepts. The connection node half owns the binding: it injects both services and registers `toFetchHandler(ctx.apiProxy)` under the `/api` prefix — future IPC carriers swap connection's transport while the gateway stays untouched. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns the graph: incremental per-package scanning (no full-rescan code path — `internal/plugin` marks the fiber's entry name dirty, a flush reconciles each name against live entries, package metadata including negative verdicts is cached forever, re-hashing is reachable only through `rebuilt(id)`), the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload: `fs.watchFile` stat-polling driven by `onGraphChanged` membership, and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
## Consequences
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
- Headless still boots through `bootHost` (unchanged this round); its migration, the profile write path, the `$DSH_HOME` profile relocation, and IPC carriers are recorded deferrals in the design ledger.
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
## Alternatives considered
| Rejected | One-line reason |
|---|---|
| Dedicated `dsh-host-profile` receiver package | The profile json is consumed at patch time; the only runtime consumer of `{provider, model}` is the gateway itself — its config is the receiver |
| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls |
| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
| A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half |
| dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference |
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
| Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 1025% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |
@@ -0,0 +1,42 @@
# Agent Notedsh web 的 config-tree boot 与 web 传输分层
Status: implemented
[English](2026-07-24-web-config-tree-boot-and-transport-layering.md) | 中文
> 范围:`dsh web` 如何组合(cordis.yml + cordis 之前的 boot 类 + 配置源),以及 web 传输如何跨包分层(网关 / 载体 / 绑定 / 图 / 开发期重载)。浏览器侧装载链归 [client 插件装载 note](2026-07-23-client-plugin-loading-model.md) 所有,本组合只是它的供给方。
## 问题
`dsh web` 曾是仅剩的手工装配面:`bootHost` 逐个挂 32 个插件、config 钉死在代码里(违反 no-hardcoded-tunables),client roster 是 `web.ts` 常量,而 TUI/headless 早已是 yml 组合。传输层的职责错位与之配套:webserver 自称哑载体却认识 `__DSH_BOOT__` 图、拥有 SSE 通道、硬编码 `/api/*` 前缀;dev 的 bundle watch 寄居在 prod registry 里靠 `watch?` 参数开关、生命周期无主;图 registry 对每次 `internal/plugin` 全量重扫;单请求失败与致命 server 错误共用一个一律退进程的 sink。还有一个用户可见缺陷:web 路径不装 `$DSH_HOME/.env``DSH_HOME=… dsh web` 读不到自定义 home 下的 API key。
## 决策
**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime32 行)、`api-gateway` 行、`webserver` 行、十个 `dshClient` 行(浏览器 rostermodules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweepPENDING fiber——cordis inject 等待没有超时)。
**boot 胶水是一对 class。** `AppCLIEntry`apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 envambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。
**每个配置源有唯一声明位置。** yml 静态值是工程默认;profile json`./.dsh-tmp-profile/config.json`,只读、绝不创建、暂锚 cwd 直至 `$DSH_HOME` 迁移)是用户配置,经静态 `PROFILE_MAPPINGS` 表映射到目标行(`provider`/`model``api-gateway` 行,`persistenceRoot` → jsonl 行);CLI flags 映射到 `webserver` 行、字段集与 json 不相交;env 值经 yml `!!js` 表达式进入,绝不进映射表。patch 整体替换行 config,故 entry 类旁路 parse 重读 yml 行静态值再叠加覆盖。未映射的 json 键 fail loud。解析出的前端 `distIndex` 走同一 patch 通道——装配事实,不是用户配置。
**传输五分。** `dsh-host-apiproxy` 升格网关插件(`api-gateway` 行):默认导出 `ApiProxyService`config `{provider, model}`provide `ctx.apiProxy`,传输无关、不注册路由——`createApiProxy` 从 runtime 迁入(依赖方向允许;runtime 保留 `bootHost`/`startHost` 供 headless)。`dsh-host-webserver` 缩成朴素路由注册插件:`HttpServerService` provide `ctx.httpServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败答 400 并记日志不退进程,不认识任何 harness 概念。connection node 半拥有绑定:inject 两个服务,把 `toFetchHandler(ctx.apiProxy)` 注册在 `/api` 前缀下——将来 IPC 载体只换 connection 的传输,网关零改动。modules node 半(`ClientModuleHostService`provide `ctx.clientModuleHost`)拥有图:单包增量扫描(无全量重扫路径——`internal/plugin` 把 fiber 的 entry 名标脏,flush 逐名对账 live entries,包元数据含否定结论永久缓存,重哈希唯一入口 `rebuilt(id)`)、bundle 路由、index tap、`onRebuilt`/`onGraphChanged` 通知。hmr node 半拥有开发期重载:`fs.watchFile` stat 轮询、watch 集合跟随 `onGraphChanged``/plugins/events` SSE 路由。
**包出口纪律。** modules 包只暴露 `.`node 半)与 `./client`(完整浏览器半:`ClientModuleSystem``parseBootManifest`、收编插件面)——不设特设子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__``./client` 的 apply 读槽(缺槽大声抛)并 provide `ctx.modules`
## 后果
- 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins``CLIENT_PACKAGES``createHostWebPluginRegistry``startWebServer`、webserver 的图/SSE/api 知识)全部删除。
- headless 本轮仍走 `bootHost`;它的迁移、profile 写入路径、profile 迁 `$DSH_HOME`、IPC 载体,均为设计台账中的挂账项。
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
## Alternatives considered
| 弃案 | 一行理由 |
|---|---|
| 专门的 `dsh-host-profile` 受体包 | profile json 在 patch 阶段消费完;`{provider, model}` 的唯一运行时消费方是网关自己——受体即网关 config |
| runtime 里的 `assembly` 垫层插件(provide `apiHandler` | 它的存在只因 `createApiProxy` 住 runtime;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 |
| 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 |
| modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 |
| dev overlay / `cordis.dev.yml` | 一套 yml`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 |
| env 进映射表 | 同一字段将出现 env/json 双源,需再发明优先级 |
| create 不等预取(以 `arrive()` 去重为安全依据) | 被 10–25% boot 竞态证伪:在途去重只覆盖同包双拉,不覆盖跨包同步 require 边 |
| json 直接当 loader patches 文件 | json 键名将耦合 yml 行结构,写入方要懂 cordis |
@@ -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
2026-07-20-code-mode-result-card-completeness.md: 03c14cd780832fa03977dade2c7d14feb0399369
2026-07-20-code-mode-result-card-completeness.zh.md: 45047cc5bcb8b74668702302077ff91fd3ff6bdc
2026-07-20-code-mode-result-card-completeness.md: 97cd9d722e8252b956e16da03c3b8418451350f3
2026-07-20-code-mode-result-card-completeness.zh.md: fea162b073e3473f7a07d4bf054408d28851fea9
@@ -6,7 +6,7 @@ English | [中文](2026-07-20-code-mode-result-card-completeness.zh.md)
## Problem
The outer `run_code` tool persisted complete rendered content, but its editor presenter ignored that content and rebuilt the card body from a logs-only `presentationMeta` projection. A result-only run appeared correct because an empty presenter body let ACP and TUI fall back to `tool/result.content`. Once the program emitted a log, the presenter supplied non-empty content, that fallback stopped, and the returned value disappeared from the completed card. A spill policy's final head/tail preview was vulnerable to the same split ownership whenever captured logs made the stale projection non-empty.
The outer `run_code` tool persisted complete rendered content, but its UI presenter ignored that content and rebuilt the card body from a logs-only `presentationMeta` projection. A result-only run appeared correct because an empty presenter body let consumers fall back to `tool/result.content`. Once the program emitted a log, the presenter supplied non-empty content, that fallback stopped, and the returned value disappeared from the completed card. A spill policy's final head/tail preview was vulnerable to the same split ownership whenever captured logs made the stale projection non-empty.
Nested Code calls never owned cards, so producing metadata for the outer call solely to reconstruct one incomplete card also obscured the intended one-card boundary.
@@ -22,7 +22,7 @@ Nested dispatch remains unchanged. Calls marked by `exec.parent` emit bounded `t
Tool unit coverage drives logs-only, result-only, logs-plus-result, no-output, spilled-result, and failure outcomes through the canonical registry, then pins the durable content and absence of a result presenter. A host-mux regression uses a call-only presenter to prove the result frame carries raw content exactly once and no view. These cases prove stale metadata cannot replace final content without making the host duplicate that content.
The keyless ACP and TUI Code Mode snapshots execute one outer program that performs two nested bash calls, logs `captured output`, and returns `CODE_ONE+CODE_TWO`. Both surfaces show one completed outer card containing both lines and no nested cards.
The keyless ACP backend and TUI Code Mode snapshots execute one outer program that performs two nested bash calls, logs `captured output`, and returns `CODE_ONE+CODE_TWO`. The persisted ACP log pins the complete result; the TUI surface shows one completed outer card containing both lines and no nested cards.
## Alternatives considered
@@ -30,10 +30,10 @@ The keyless ACP and TUI Code Mode snapshots execute one outer program that perfo
**Merge presenter metadata with `result.content`.** Rejected because the rendered content already contains the logs; merging would duplicate them and require brittle deduplication.
**Forward `result.content` through a generic result presenter.** Rejected because the durable event already carries that content and ACP/TUI already have a generic raw-content fallback. The host mux serializes a tool-owned result view beside the event, so forwarding would duplicate the rendered content in one frame merely to recreate the fallback; the default worker alone admits a 64 MiB variable-payload budget before rendering.
**Forward `result.content` through a generic result presenter.** Rejected because the durable event already carries that content and UI consumers already have a generic raw-content fallback. The host mux serializes a tool-owned result view beside the event, so forwarding would duplicate the rendered content in one frame merely to recreate the fallback; the default worker alone admits a 64 MiB variable-payload budget before rendering.
**Create one card per nested dispatch.** Rejected because intermediate values are intentionally execution-local and never model-facing. Multiple cards would expose an implementation trace instead of the single Code Mode operation the model and user invoked.
## Consequences
ACP and TUI display the same complete content the model receives and replay persists, including post-policy spill previews, through their generic result fallback. The host API retains the pending program title without duplicating the raw result in a separate view payload. New `run_code` results no longer carry the optional logs metadata, but this requires no session-format bump: existing records remain valid because presentation reads their durable rendered content.
TUI and JSON-RPC/Web display the same complete content the model receives and replay persists, including post-policy spill previews, through their generic result fallback. The host API retains the pending program title without duplicating the raw result in a separate view payload. New `run_code` results no longer carry the optional logs metadata, but this requires no session-format bump: existing records remain valid because presentation reads their durable rendered content.
@@ -6,7 +6,7 @@ Status: implemented
## 问题
外层 `run_code` 工具会持久化完整的渲染内容,但编辑器的卡片展示逻辑忽略了这些内容,转而根据仅含日志的 `presentationMeta` 投影重新构建卡片正文。仅有结果的运行看似正确,是因为展示逻辑未提供正文时,ACP 和 TUI 会回退到 `tool/result.content`。只要程序输出一条日志,展示逻辑就会提供非空内容,回退随即停止,返回值便会从完成态卡片中消失。当已捕获的日志使陈旧投影变为非空时,输出落盘策略最终生成的头尾预览也会受到同一职责拆分的影响。
外层 `run_code` 工具会持久化完整的渲染内容,但其 UI 展示器忽略了这些内容,转而根据仅含日志的 `presentationMeta` 投影重新构建卡片正文。仅有结果的运行看似正确,是因为展示器正文为空时,消费方会回退到 `tool/result.content`。只要程序输出一条日志,展示就会提供非空内容,回退随即停止,返回值便会从完成态卡片中消失。当已捕获的日志使陈旧投影变为非空时,输出落盘策略最终生成的头尾预览也会受到同一职责拆分的影响。
嵌套 Code 调用从不生成自己的卡片。因此,仅仅为了重建这一张不完整卡片而给外层调用生成元数据,还掩盖了每次外层调用只生成一张卡片的预期边界。
@@ -22,7 +22,7 @@ Status: implemented
工具单元测试通过规范注册表覆盖仅有日志、仅有结果、日志与结果并存、无输出、结果落盘和失败的结果,然后固定持久内容以及结果展示器不存在这一事实。宿主 mux 回归测试使用仅有调用的展示器,证明结果帧恰好携带一次原始内容,且不含视图。这些案例证明陈旧元数据无法替换最终内容,同时不会让宿主重复该内容。
无密钥的 ACP 与 TUI Code Mode 快照会执行一个外层程序:程序进行两次嵌套 bash 调用,记录 `captured output`,并返回 `CODE_ONE+CODE_TWO`两个界面只显示一张完成态外层卡片,其中包含这两行内容,且没有嵌套卡片。
无密钥的 ACPAgent Client Protocol)后端快照与 TUI Code Mode 快照会执行一个外层程序:程序进行两次嵌套 bash 调用,记录 `captured output`,并返回 `CODE_ONE+CODE_TWO`ACP 持久化日志固定完整结果;TUI 界面只显示一张完成态外层卡片,其中包含这两行内容,且没有嵌套卡片。
## 备选方案
@@ -30,10 +30,10 @@ Status: implemented
**把展示元数据与 `result.content` 合并:**不予采纳。渲染内容已经包含日志;合并会造成重复,还需要依赖脆弱的去重逻辑。
**通过通用结果展示器转发 `result.content`:**不予采纳。持久事件已经携带该内容,ACP 和 TUI 也已有通用的原始内容回退机制。宿主 mux 会在事件旁序列化工具拥有的结果视图,因此转发仅仅是为了重建该回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算。
**通过通用结果展示器转发 `result.content`:**不予采纳。持久事件已经携带该内容,UI 消费方也已有通用的原始内容回退机制。宿主 mux 会在事件旁序列化工具拥有的结果视图,因此转发仅仅是为了重建该回退机制,却会在一个帧中重复渲染内容;仅默认 worker 在渲染前允许 64 MiB 的可变载荷预算。
**为每次嵌套分发创建一张卡片:**不予采纳。中间值有意只存在于执行期间,永远不面向模型。多张卡片会暴露实现轨迹,而不是模型与用户调用的单次 Code Mode 操作。
## 影响
ACP 和 TUI 通过通用结果回退机制显示与模型接收及回放持久化相同的完整内容,其中包括 post-policy 输出落盘预览。宿主 API 保留待完成的程序标题,同时不在单独的视图负载中重复原始结果。新的 `run_code` 结果不再携带可选的日志元数据,但无需提升会话格式版本:现有记录仍然有效,因为展示逻辑会读取其中持久化的渲染内容。
TUI 与 JSON-RPCWeb 通过通用结果回退机制显示与模型接收及回放持久化相同的完整内容,其中包括 post-policy 输出落盘预览。宿主 API 保留待完成的程序标题,同时不在单独的视图负载中重复原始结果。新的 `run_code` 结果不再携带可选的日志元数据,但无需提升会话格式版本:现有记录仍然有效,因为展示逻辑会读取其中持久化的渲染内容。
@@ -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
2026-06-14-acp-agent-client-protocol.md: c6976ed28a254684fca62e2d309cdde7ea90340d
2026-06-14-acp-agent-client-protocol.zh.md: 6bd234f2aa3f43f4fc6cbd921161253c46aab73e
2026-06-14-acp-agent-client-protocol.md: da23bbfa247bc2423072477cc4b6277485df1c9c
2026-06-14-acp-agent-client-protocol.zh.md: ec55922e0aee57169d8bbf44c3d91b18ea83041e
@@ -4,6 +4,8 @@ Status: implemented
English | [中文](2026-06-14-acp-agent-client-protocol.zh.md)
> Superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). This note records the retired editor-facing bridge design.
## Problem
The harness originally exposed agents only through a readline loop. That surface could carry text, but it gave an editor no structured way to create or resume sessions, correlate prompt completion, stream reasoning and tool activity, render tool-specific UI, ask for permission, or cancel one conversation without disturbing another. ACP defines those interactions as JSON-RPC over stdio, and Zed is the target client used to make concrete compatibility decisions.
@@ -12,7 +14,7 @@ The bridge must preserve the harness's existing ownership boundaries. It cannot
## Decision
`@deepseek-ai/dsh-acp` is a UI/client-driver plugin under `packages/ui/acp`. It uses `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programs only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It does not change the agent loop and is not a capability-seam implementation.
`@deepseek-ai/dsh-acp` was a UI/client-driver plugin in the `ui` package group (it now lives in `acp`). It used `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programmed only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It did not change the agent loop and was not a capability-seam implementation.
The bridge implements the following stable session path:
@@ -32,7 +34,7 @@ The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_que
Lifecycle ownership is explicit. The bridge holds an `AgentHandle` per live session. Disconnect and Cordis disposal cancel pending prompts, dispose every handle in parallel, await loop quiescence and persistence flush, and then remove the records. Stream notification failures are contained so a vanished client cannot corrupt an agent turn. The ACP app composition loads no stdout logger; a test guards stdout as framed JSON-RPC only.
The precise supported and deferred protocol rows live in [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md); the package README is the operational contract.
The current protocol contract lives in the [`dsh-acp` package README](../../../../packages/acp/acp/README.md).
## Alternatives considered
@@ -4,6 +4,8 @@ Status: implemented
[English](2026-06-14-acp-agent-client-protocol.md) | 中文
> 已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。本 Agent Note 记录已退役的面向编辑器的桥接层设计。
## 问题
harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACPAgent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。
@@ -12,7 +14,7 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本
## 决策
`@deepseek-ai/dsh-acp` 是位于 `packages/ui/acp` 的 UI/客户端驱动插件。它使用 `@agentclientprotocol/sdk``AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。
`@deepseek-ai/dsh-acp` 曾是 `ui` 包组中的 UI/客户端驱动插件(现位于 `acp`。它使用 `@agentclientprotocol/sdk``AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。
桥接层实现以下稳定的会话路径:
@@ -32,7 +34,7 @@ harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本
生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环完全停稳与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。
精确的已支持与已推迟的协议行列表见 [`packages/ui/acp/acp-feature-support.md`](../../../../packages/ui/acp/acp-feature-support.md);包 README 是操作契约
当前的协议契约见 [`dsh-acp` 包 README](../../../../packages/acp/acp/README.md)
## 曾考虑的替代方案
@@ -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
2026-06-14-acp-multi-session.md: 43aa32a43fef7aa69f5efd45d4c8176da9f459cd
2026-06-14-acp-multi-session.zh.md: 6835f9623b89dc20d6c7b3054365cd9c39402aeb
2026-06-14-acp-multi-session.md: 088fe984fbc94fe0d8654702d5fce9eb0581cd3b
2026-06-14-acp-multi-session.zh.md: 2a803a6eff9b16b48fb90b2b986f74e762e5419c
@@ -4,19 +4,21 @@ Status: implemented
English | [中文](2026-06-14-acp-multi-session.zh.md)
> Written when ACP was an editor bridge, motivated by Zed's multi-session client model. [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removed the editor surfaces; the multiplexing decision itself is unchanged and this note now states it against the automation contract.
## Problem
An ACP editor can keep several conversations alive over one agent subprocess. A single-active-session bridge would force extra processes and would not match Zed's client model, which tracks multiple session ids and concurrent loads. Multiplexing introduces isolation risks: events, prompt completion, cancellation, permission prompts, config selections, and predictable background-task ids must never cross session boundaries.
An ACP automation client can keep several conversations alive over one agent subprocess. A single-active-session bridge would force extra processes and prevent one parent controller from driving independent children over one connection. Multiplexing introduces isolation risks: committed answers, prompt completion, cancellation, permission requests, and predictable background-task ids must never cross session boundaries.
## Decision
The ACP bridge stores live sessions in `Map<SessionId, SessionRecord>`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently.
The ACP bridge stores live sessions in `Map<SessionId, SessionRecord>`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent, exact disposer, and optional in-flight prompt with the durable turn number that eventually settles it. The session header owns its cwd; the bridge keeps no parallel workspace or client-capability state.
Every `session/event` and `agent/status` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt records a log watermark, captures its own `turn/start`, and settles only on the matching `turn/end`; a late end from a cancelled prior turn cannot resolve a newer prompt. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path.
Every `session/event` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt captures its own user-sourced message `turn/start` and settles only on the matching `turn/end`; injection turns, autonomous plugin or goal turns, and a late end from a cancelled prior turn cannot resolve it. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path.
Permission ownership uses the same exact-agent check against the forward map. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them.
Permission ownership uses the same exact-agent check against the forward map. The ACP `approval/request` answerer sends a one-shot machine-policy request only for the session that owns the requesting agent and delegates foreign or call-less requests. The bridge has no elicitation, config-selection, or other human-interaction state.
Background bash tasks carry an opaque owner token equal to the owning session id. `bash_output` and `bash_kill` compare the caller's token with the executor's task ownership before reading or killing; a predictable task id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it.
Background bash tasks carry an opaque owner token equal to the owning session id. `task_output` and `task_kill` compare the caller's token with the executor's task ownership before reading or killing; a predictable task id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it.
Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal.
@@ -24,13 +26,13 @@ Connection teardown clears the live map, settles each pending prompt as cancelle
[ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session.
A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). [Zed sends the remaining project work directories only when the agent advertises that capability](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145), otherwise it [drops them from the session request](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472). The bridge does not advertise this capability and rejects non-empty values, as recorded in its [known limitations](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work), so a current Zed multi-root project reaches it with only the first work directory.
A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). The automation bridge advertises no multi-root capability and rejects non-empty `additionalDirectories`; each fresh session has exactly one workspace, as recorded in the [package contract](../../../../packages/acp/acp/README.md#protocol-contract).
[The standard transport is one editor-launched agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple editor connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path.
[The standard transport is one agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path.
## Alternatives considered
**One live session per connection** — rejected. It adds process overhead and contradicts the target client's multi-session shape without removing multiplexing needs from the editor.
**One live session per connection** — rejected. It adds process overhead and prevents a programmatic parent from multiplexing independently cancellable work.
**A per-session `ctx.extend()`** — rejected. A child context does not by itself create a child plugin fiber, so listeners would still belong to the bridge fiber. The implemented bridge instead uses global listeners with explicit O(1) demultiplexing and per-session owned records; agent lifecycle is owned by `AgentHandle`.
@@ -38,10 +40,10 @@ A multi-root project inside one session is a separate optional capability: ACP d
## Consequences
N sessions can stream, prompt, request permission, switch config, and run background tasks concurrently without interleaving or cross-settling. A cancel or dispose in one session does not affect its neighbors. The bridge pays for explicit maps and isolation tests, but it does not add one listener set per session and therefore avoids listener fan-out during long-lived connections.
N sessions can return committed answers, prompt, request permission, and run background tasks concurrently without interleaving or cross-settling. A cancel in one session does not affect its neighbors. The bridge pays for explicit maps and isolation tests, but it does not add one listener set per session and therefore avoids listener fan-out during long-lived connections.
The bridge still exposes no protocol method to close one live session independently. Today records leave together on connection teardown; session close/resume lifecycle capabilities remain deferred in the ACP feature checklist.
The bridge exposes no protocol method to close one live session independently. Records leave together on connection teardown; navigation and resume belong to host APIs rather than this automation protocol.
## Verification
The multi-session suite drives concurrent sessions through interleaved updates, independent in-flight prompts, targeted cancellation, same-id and distinct-id load races, permission routing, config isolation, and teardown. Tool-bash tests prove one session cannot read or kill another session's background task.
The multi-session suite drives concurrent sessions through routed committed answers, independent in-flight prompts, targeted cancellation, and shared teardown; the approval and output-boundary suites cover permission routing and exact-agent rejection. Tool-bash tests prove one session cannot read or kill another session's background task.
@@ -4,19 +4,21 @@ Status: implemented
[English](2026-06-14-acp-multi-session.md) | 中文
> 本 Agent Note 写于 ACP 还是编辑器桥接层的时期,动机来自 Zed 的多会话客户端模型。[ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)移除了编辑器接口;多路复用决策本身不变,本 Agent Note 现依照自动化契约陈述它。
## 问题
一个 ACPAgent Client Protocol编辑器可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也无法匹配 Zed 的客户端模型——该模型跟踪多个会话 id 和并发加载。多路复用引入了隔离风险:事件、提示词完成、取消、权限提示、配置选择以及可预测的后台 task id 绝不能跨越会话边界。
一个 ACPAgent Client Protocol自动化客户端可以在同一个 agent(智能体)子进程上保持多个对话。如果桥接层只支持单活跃会话,就不得不启动额外进程,也会阻止一个父控制器通过一条连接驱动多个独立子任务。多路复用引入了隔离风险:已提交的回答、提示词完成、取消、权限请求以及可预测的后台 task id 绝不能跨越会话边界。
## 决策
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent 句柄、进行中提示词、活跃的工具调用展示状态、待处理的空闲配置切换、会话 cwd 以及客户端能力快照。一个独立的 loading-id 集合在异步恢复之前预留每个 id,使两个流水线化的加载请求无法构造出重复的 agent;不同 id 可以并发加载
ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent、精确的释放器,以及可选的进行中提示词和最终结算它的持久轮次号。会话 header 拥有其 cwd;桥接层不保留平行的工作区或客户端能力状态
每个 `session/event``agent/status` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的提示词。提示词记录一个日志水位线,捕获自己的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;来自已取消的前一轮次的迟到 end 不能 resolve 更新的提示词`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。
每个 `session/event` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的提示词。提示词捕获自己源自用户消息`turn/start`,并仅在匹配的 `turn/end` 到达时结算;注入轮次、插件或 goal 的自主轮次,以及来自已取消的前一轮次的迟到 end 不能 resolve `session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。
权限归属使用对正向 map 的同一精确 agent 检查。ACP `approval/request` 应答器只拥有发起请求的 agent 的编辑器会话发起提示,并将外部请求委托出去。用户交互引出同样按 agent 归属路由。每会话的沙箱和审批配置值只折叠该会话自身的事件,待处理的空闲切换存储在该记录上,直到下一轮次将其锚定
权限归属使用对正向 map 的同一精确 agent 检查。ACP `approval/request` 应答器只拥有发起请求的 agent 的会话发送一次性机器策略请求,并将外部请求或不带 call id 的请求委托出去。桥接层没有表单引导、配置选择或其他人机交互状态
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`bash_output``bash_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。
后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`task_output``task_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 task id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。
连接拆除时清空活跃 map,将每个待处理的提示词以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。
@@ -24,13 +26,13 @@ ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agen
[ACP v1 明确允许一个连接上存在多个并发会话](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24),每个新会话都携带自己的主 `cwd`。本桥实现该会话级多路复用,其中包括[按会话 cwd 决策](../architecture/2026-07-02-fs-per-session-cwd.md)所记录的不同主工作区;它不会为每个会话创建一个 agent 子进程。
一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。[Zed 仅在 agent 公布该能力时发送其余项目工作目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1139-L1145),否则会[从会话请求中丢弃这些目录](https://github.com/zed-industries/zed/blob/ea77ca2818f3e059a2b61ecc7e63b67e01e1cec5/crates/agent_servers/src/acp.rs#L1454-L1472)。如其[已知限制](../../../../packages/ui/acp/README.md#known-limitations-and-deferred-work)所记录,桥不公布该能力,并拒绝非空值,因此当前 Zed 多根项目到达桥时只携带第一个工作目录
一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。自动化桥接层不公布任何多根能力,并拒绝非空的 `additionalDirectories`;如[包契约](../../../../packages/acp/acp/README.md#protocol-contract)所记录,每个全新会话恰好有一个工作
[标准传输是每个 stdio 连接一个由编辑器启动的 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个编辑器连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。
[标准传输是每个 stdio 连接一个 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。
## 曾考虑的替代方案
**每连接单活跃会话**:否决。增加进程开销,与目标客户端的多会话形态相矛盾,且并未消除编辑器端的多路复用需求
**每连接单活跃会话**:否决。增加进程开销,并阻止程序化的父控制器多路复用可独立取消的工作
**每会话 `ctx.extend()`**:否决。子上下文本身不会创建子插件 fiber,因此监听器仍属于桥接层 fiber。实际实现的桥接层使用全局监听器加显式 O(1) 解复用,以及每会话拥有的记录;agent 生命周期由 `AgentHandle` 管理。
@@ -38,10 +40,10 @@ ACP 桥接层将活跃会话存储在 `Map<SessionId, SessionRecord>` 中。agen
## 后果
N 个会话可以并发地进行流式输出、提示词、权限请求、配置切换和后台任务运行,而不会交错或跨会话结算。一个会话中的取消或 dispose 不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。
N 个会话可以并发地返回已提交的回答、提交提示词、请求权限和运行后台任务,而不会交错或跨会话结算。一个会话中的取消不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。
桥接层目前仍未暴露独立关闭单个活跃会话的协议方法。当前所有记录在连接拆除时一起离开;会话关闭/恢复的生命周期能力在 ACP 功能清单中仍处于延期状态
桥接层暴露独立关闭单个活跃会话的协议方法。所有记录在连接拆除时一起离开;会话导航与恢复属于 host API,而非这个自动化协议
## 验证
多会话测试套件通过交错更新、独立的进行中提示词、定向取消、相同 id 与不同 id 的加载竞争、权限路由、配置隔离以及拆除来驱动并发会话。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
多会话测试套件通过按路由投递的已提交回答、独立的进行中提示词、定向取消以及共享拆除来驱动并发会话;审批与输出边界套件覆盖权限路由和精确 agent 拒绝。工具 bash 测试证明一个会话无法读取或终止另一个会话的后台任务。
@@ -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
2026-06-15-code-mode.md: 94a95ba2c5760c0fed6ebfe09330546f401c42c4
2026-06-15-code-mode.zh.md: 0ba5d69258bce23a24db9a0b470ddb074840cba7
2026-06-15-code-mode.md: 8e964eeb36e430b58312427e45cf8e4a99582457
2026-06-15-code-mode.zh.md: 3de1adfcf287b69ca8307244bde35f286ce4b99d
@@ -50,7 +50,7 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat
**Concurrency is serialized.** Each run owns a dispatch queue, so even `Promise.all` executes tool calls in submission order. Settlement abandons queued calls that have not started. Parallelism requires per-tool concurrency-safety metadata.
**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so ACP and TUI complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. The program is the title because ACP execute cards reliably render that field while some clients omit body and raw-input content. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../bug-fix/2026-07-20-code-mode-result-card-completeness.md).
**Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../bug-fix/2026-07-20-code-mode-result-card-completeness.md).
### Observability: `tool/code-dispatch`
@@ -50,7 +50,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
**并发被序列化。** 每次 run 拥有一个分发队列,因此即使 `Promise.all` 也按提交顺序执行工具调用。结算时放弃尚未开始的排队调用。并行化需要每个工具的并发安全元数据。
**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput``run_code` 有意不声明 `presentResult`,因此 ACP 和 TUI 会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy 输出落盘预览。程序作为标题是因为 ACP execute 卡片可靠地渲染该字段,而某些客户端会省略 body 和 raw-input 内容。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../bug-fix/2026-07-20-code-mode-result-card-completeness.md)。
**呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput``run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy 输出落盘预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../bug-fix/2026-07-20-code-mode-result-card-completeness.md)。
### 可观测性:`tool/code-dispatch`
@@ -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
2026-06-18-acp-terminal-and-tool-rendering.md: 166e52f8e659a78b1347279c61956ca1001e4939
2026-06-18-acp-terminal-and-tool-rendering.zh.md: 1623c8f51f9a1e0aacf374d182ab1c74dfa407cc
2026-06-18-acp-terminal-and-tool-rendering.md: e8426dbf1a0e3e4f9d9857baa19945cee6eee4b3
2026-06-18-acp-terminal-and-tool-rendering.zh.md: ff269e002a0ecea8c0bacf53fe85e553a6b9b9d5
@@ -4,6 +4,8 @@ Status: implemented
English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md)
> Superseded for ACP by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). Tool render intents remain available to UI transports, but ACP no longer projects them into terminal cards.
## Problem
The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block.
@@ -4,6 +4,8 @@ Status: implemented
[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文
> 就 ACP 而言已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。工具渲染意图对 UI 传输层仍然可用,但 ACP 不再将其投影为终端卡片。
## 问题
ACPAgent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。
@@ -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
2026-06-25-ask-user-question.md: 9eab7aeb17774961dfc40224accdcfcbd5eb9d5f
2026-06-25-ask-user-question.zh.md: eb166acfef7ed8f7ab4eb3018bf161a9ecc3549e
2026-06-25-ask-user-question.md: 51b9a67bfc0fdc88d84a9fa51662d02b127f7925
2026-06-25-ask-user-question.zh.md: 4bd2c2b8664ba085919eb9d4b8c5dee92d4bf72e
@@ -18,7 +18,7 @@ The model-facing request vocabulary is deliberately aligned with the product-res
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty. A provider that supports partial completion represents a deliberately skipped item with the existing `{ id, selected: [] }` shape, preserving the other answers without extending the tool result vocabulary.
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, or missing request ownership survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
## UI mappings
@@ -28,9 +28,7 @@ The Web composer shows one question at a time while retaining every request in t
`dsh-tui` renders each question as a keyboard overlay, shows option descriptions, supports single- and multi-select choices plus free-form custom answers, and rejects pending questions on abort, provider disposal, or terminal shutdown. Batched and simultaneous requests are queued so one overlay owns keyboard focus at a time.
`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different.
An ACP elicitation mapping existed while the bridge was an editor UI; [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removed that third mapping.
## Alternatives considered
@@ -38,18 +36,16 @@ The ACP mapping deliberately uses elicitation, not `session/request_permission`.
**Core-owned ask-user packages.** The first implementation split the seam and the model-facing tool across `packages/core` and `packages/ui`, but both names describe one UI-backed human-interaction affordance. The seam remains provider-neutral, but it is not providerless core infrastructure like sessions, tools, or the agent registry. Keeping `dsh-user-interaction` and `dsh-tool-ask-user` together under `packages/ui` makes the package map match the product boundary: apps and bridges provide the human-answer provider, and the stdio app opts into the model-facing tool.
**ACP `session/request_permission`.** Permission requests are authorization around tool execution; `ask_user_question` is information gathering with optional free-form answers. Using permission for general questions would collapse two different product concepts and make the future permission gate harder to reason about.
**Use a permission request for general questions.** Permission requests authorize tool execution; `ask_user_question` gathers information with optional free-form answers. Reusing the permission channel would collapse two different product concepts.
**A loop-level pause primitive.** The agent loop already knows how to await a tool call and resume from a tool result. Adding a new loop special case would duplicate that async shape and make every loop implementation learn about a UI concern.
## Consequences
ACP elicitation is currently marked unstable in the SDK. The fallback is still structured: if a client does not implement it, the tool returns `ASK_FAILED` rather than hanging. A later ACP stabilization may rename or reshape the method; that migration should stay inside `dsh-acp` because the core `ctx.userInteraction` vocabulary is provider-neutral.
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `dsh-tui-demo` opts into the seam, TUI provider, and model-facing tool. `dsh web` boots the seam/provider in the host runtime and exposes the tool through the selected Web question plugin. The ACP automation app mounts neither the seam nor the tool.
## Testing
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer.
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, explicit per-item skips, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. TUI tests cover option descriptions, queued requests, shutdown/abort cleanup, optionless free-form input, invalid choices, duplicate multi-select selections, and batched question flows. Web tests pin stable-id replay, response validation, first-wins settlement, duplicate and late responses, whole-request cancellation versus owner abort, single-select advance, IME-safe Enter submission, per-item skip preservation, composer takeover, structured batch submission, and restoration of the normal composer.
@@ -18,7 +18,7 @@ agent(智能体)有时仅凭模型推理(inference)无法安全地继续
提供方返回 `{ answers: [{ id, selected, custom? }] }``selected` 始终是选中选项 label 的数组,因此单选和 `multi_select` 的答案共享同一种结果形状。`custom` 承载自由文本的「其他」答案;无选项的问题直接收集 `custom`。当 `custom` 存在时,它覆盖任何已选择的选项,`selected` 为空。支持部分完成的提供方使用现有的 `{ id, selected: [] }` 形状表示某项被有意跳过,在不扩展工具结果词汇的前提下保留其他答案。
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER``ASK_ABORTED`、ACPAgent Client Protocol)取消或会话路由缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。
`UserInteractionError` 继承 `HarnessError`,因此 `NO_PROVIDER``ASK_ABORTED` 或请求归属缺失等失败会以机器可路由的 `{ name, code }` 工具错误形式通过 `ctx.tools.execute()` 传出。这与结构化错误分类体系一致,使模型或包装插件能够区分「用户取消」与一般的抛出异常。
## UI 映射
@@ -28,9 +28,7 @@ Web 输入区一次显示一个问题,同时在会话对象层保留每个请
`dsh-tui` 将每个问题渲染为键盘叠层,展示选项描述,支持单选、多选和自由格式自定义答案,并在中止、提供方 dispose(资源释放)或终端关闭时拒绝待处理的问题。批量请求和并发请求都会排队,确保同一时刻只有一个叠层占用键盘焦点。
`dsh-acp` 为 ACP 会话提供同一 seam。它通过 `ownedRecord` 解析调用方 `Agent`,要求位于 `agent.session.id` 的正向会话 map 记录拥有该精确 agent 对象,并为每个问题调用 ACP `unstable_createElicitation`(附带会话范围的表单)。单选选项变为 `choice` 字符串枚举;`multi_select` 选项变为 `choice` 数组枚举;无选项的问题使用必填的 `custom` 文本字段。如果客户端同时返回 `choice` 和非空 `custom`,以 custom 答案为准。ACP `decline`/`cancel`、缺失答案、缺失会话以及客户端不支持 elicitation,都会转为结构化的 `UserInteractionError`
ACP 映射有意使用 elicitation 而非 `session/request_permission``request_permission` 仍保留给独立的权限门禁:它是围绕工具执行的 yes/no 或策略式授权协议。`ask_user_question` 是一个通用的信息收集工具,支持可选的自由格式答案,因此 ACP 表单 elicitation 是更贴合的协议。bridge 的会话路由与未来的权限门禁共享,但用户意图不同。
在桥接层还是编辑器 UI 时曾存在一个 ACPAgent Client Protocolelicitation 映射;[ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)移除了这第三个映射
## 曾考虑的替代方案
@@ -38,18 +36,16 @@ ACP 映射有意使用 elicitation 而非 `session/request_permission`。`reques
**核心拥有的 ask-user 包。** 最初实现将 seam 和面向模型的工具分别放在 `packages/core``packages/ui`,但两者描述的是同一个由 UI 支撑的人机交互功能。seam 仍然是提供方无关的,但它不是像会话、工具或 agent 注册表那样的无提供方核心基础设施。将 `dsh-user-interaction``dsh-tool-ask-user` 一起放在 `packages/ui` 下,使包的划分与产品边界一致:应用和 bridge 提供人类答案的提供方,stdio 应用选择性加载面向模型的工具。
**ACP `session/request_permission`** 权限请求是围绕工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。将权限用于通用提问会混淆两个不同的产品概念,并使未来的权限门禁更难推理
**用权限请求处理通用提问** 权限请求是工具执行的授权;`ask_user_question` 是带可选自由格式答案的信息收集。复用权限通道会混淆两个不同的产品概念。
**循环级别的暂停原语。** agent loop 已经知道如何等待工具调用并从工具结果恢复。添加新的循环特殊分支会重复这一异步形状,并迫使每个循环实现都了解一个 UI 关注点。
## 后果
ACP elicitation 目前在 SDK 中标记为 unstable。回退仍然是结构化的:如果客户端未实现它,工具返回 `ASK_FAILED` 而非挂起。后续 ACP 稳定化可能重命名或重塑该方法;该迁移应限制在 `dsh-acp` 内部,因为核心 `ctx.userInteraction` 词汇是提供方无关的。
该功能赋予模型一个强大的暂停原语,因此提示词引导很重要。工具描述告诉模型:提问要简洁,尽可能使用选项。产品策略后续可以包装 `tools/execute` 来限制工具何时可用,但循环不应对其做特殊处理。
`dsh-user-interaction``dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或提供方。`dsh-tui-demo` 选择性加载 seam、TUI 提供方和面向模型的工具。`dsh web` 在 host 运行时启动 seam/提供方,并通过选定的 Web question 插件暴露该工具。`acp-agent` 默认只保留 `userInteraction` seam/提供方:ACP elicitation 支持仍取决于客户端,因此 ACP 叶节点必须在其客户端能完成 elicitation 请求后才有意加载面向模型的工具。
`dsh-user-interaction``dsh-tool-ask-user` 都位于 `packages/ui`,因为它们共同构成一个面向产品的人机交互能力。`agent-core` 不加载工具或提供方。`dsh-tui-demo` 选择性加载 seam、TUI 提供方和面向模型的工具。`dsh web` 在 host 运行时启动 seam/提供方,并通过选定的 Web question 插件暴露该工具。ACP 自动化应用既不挂载 seam 也不挂载该工具。
## 测试
单元覆盖率固定了以下场景:提供方注册/释放、重复提供方拒绝、提供方就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案、显式按项跳过,以及模型 schema(包括移除 `value``recommended``allow_custom``desc`)。TUI 测试覆盖选项描述、排队请求、关闭/中止清理、无选项自由格式输入、无效选择、重复多选和批量问题流。ACP bridge 测试驱动一个真实的内存 ACP 连接(使用真实的 `ask_user_question` 工具),验证选中选项、custom 覆盖 choice、多选和无选项自由格式 elicitation 路径能继续 agent loop。Web 测试固定稳定 id 重放、响应校验、首个响应胜出的结算、重复和迟到响应、整个请求的取消与拥有方中止的区别、单选后前进、IME 安全的 Enter 提交、按项跳过保留、输入区接管、结构化批量提交,以及常规输入区的恢复。
单元覆盖率固定了以下场景:提供方注册/释放、重复提供方拒绝、提供方就绪前中止、空问题拒绝、通过 `ctx.tools.execute()` 传出的结构化工具错误、批量答案、多选答案、自定义答案、显式按项跳过,以及模型 schema(包括移除 `value``recommended``allow_custom``desc`)。TUI 测试覆盖选项描述、排队请求、关闭/中止清理、无选项自由格式输入、无效选择、重复多选和批量问题流。Web 测试固定稳定 id 重放、响应校验、首个响应胜出的结算、重复和迟到响应、整个请求的取消与拥有方中止的区别、单选后前进、IME 安全的 Enter 提交、按项跳过保留、输入区接管、结构化批量提交,以及常规输入区的恢复。
@@ -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
2026-06-29-todo-write-tool.md: bf5fceaafd475224914212beb460fdbc42e3dd68
2026-06-29-todo-write-tool.zh.md: 3afc03a393284e2a9c2d6e234bc95f0faebbfb48
2026-06-29-todo-write-tool.md: df1bee2801b0e01b290b63f6edbe2e5b1be80cb7
2026-06-29-todo-write-tool.zh.md: 7fa5cb2aad2b32ef0662df04ff6576be14a3a8e7
@@ -6,31 +6,27 @@ English | [中文](2026-06-29-todo-write-tool.zh.md)
## Problem
The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep the active task unambiguous (at most one active, exactly one while work remains), and it gives the human a live progress checklist. The ACP protocol has a native `plan` sessionUpdate that editors (Zed) already render, but the bridge never emitted one. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing.
The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep the active task unambiguous (at most one active, exactly one while work remains), and it gives an interactive host a live progress checklist. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing.
## Decision
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Both the stdio UI and the ACP bridge render off the existing `session/event` — the ACP bridge maps the list to a `plan` sessionUpdate.
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Interactive hosts render from the durable event; the TUI folds it directly, while the [automation-only ACP bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately omits todo presentation.
### Whole-list replace, three-state status
The model sends the ENTIRE list every call; the new list replaces the old (last-write-wins on replay). This is the shape claude-code V1, opencode, and codex `update_plan` all use, and the shape the model is most trained on — no per-item ids, no delta protocol. `status` is exactly `pending | in_progress | completed`: the same triple as codex `update_plan` and, crucially, **identical to the ACP `PlanEntryStatus`**, so the bridge maps it 1:1 with no lossy translation.
The model sends the entire list every call; the new list replaces the old (last-write-wins on replay). This is the shape claude-code V1, opencode, and codex `update_plan` all use, and the shape the model is most trained on — no per-item ids, no delta protocol. `status` is exactly `pending | in_progress | completed`, the same triple as codex `update_plan`; it also matched the ACP `PlanEntryStatus` 1:1 while the bridge projected todo lists as `plan` updates, a mapping retired with the [automation-only ACP contract](../simplification/2026-07-23-acp-automation-only-protocol.md).
### State on the session log, not a service
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that.
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and resume reconstruction for free: a reopened session re-derives the current list from the latest `todo/write`, with no separate persistence backend, in-memory service to rehydrate, or extra wiring. An in-memory `ctx.todos` service would have to reinvent all of that.
### NOT a surface event
`todo/write` is deliberately excluded from `SurfaceEventType`. The surface is the projection that produces the LLM message history (`deriveMessages()`); a todo write produces no conversation message. So it carries no `surfaceOp`, never joins the ordered surface, and never reaches `deriveMessages()` — it is durable, replayable *UI* state that travels alongside the conversation without being part of it. (The dev-mode invariants still require it to sit inside an open turn, which it always does: it is appended mid-step during a tool call.)
### Priority synthesized only at the ACP boundary
ACP's `PlanEntry` requires `content` + `priority` + `status`, but a `TodoItem` has no priority — the model never reasons about it. Rather than burden the schema with a field the model must always supply, the bridge synthesizes a constant `priority: 'medium'` on every entry when it builds the `plan`. Priority is an ACP wire requirement, not a harness concept, so it lives at exactly the boundary that needs it.
### Dropped vs claude-code V1: `activeForm`, id, priority
claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew ids, dependencies, and ownership — but only to support agent *swarms* (disk-backed, lock-guarded, per-item mutation). This tool keeps the item at the minimum: `{ content, status }`. No `activeForm` (the present-continuous label) — the UI shows `content`; no id — whole-list replace needs no stable identity; no priority — see above. Each dropped field is one less thing the model must produce on every call.
claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew ids, dependencies, and ownership — but only to support agent *swarms* (disk-backed, lock-guarded, per-item mutation). This tool keeps the item at the minimum: `{ content, status }`. No `activeForm` (the present-continuous label) — the UI shows `content`; no id — whole-list replace needs no stable identity; no priority — that was only ever an ACP `PlanEntry` wire requirement, synthesized as a constant at the bridge boundary rather than modeled, and it left with that projection. Each dropped field is one less thing the model must produce on every call.
### Single owner — no swarm machinery (YAGNI)
@@ -47,18 +43,18 @@ The schema enforces type/required/enum. Beyond that, `execute` rejects empty or
## Testing
Four tiers, designed up front:
- **Unit** — the session event (append/snapshot-clone/last-write-wins/not-on-surface); the tool (schema shape, arg validation via the real `ctx.tools.execute`, value validation, the event append + replacement, no-agent rejection, `presentCall`, HMR-safety); the ACP `todosToPlan` mapping; the stdio render arm.
- **Unit** — the session event (append/snapshot-clone/last-write-wins/not-on-surface); the tool (schema shape, arg validation via the real `ctx.tools.execute`, value validation, the event append + replacement, no-agent rejection, `presentCall`, HMR-safety); and TUI folding.
- **Real-Loader path** — the plugin run through `Loader.unwrapExports`, asserting the namespace export shape survives (it HAS `inject`, so a stray default would crash at load — postmortem/0001).
- **Full-loop integration** — a scripted mock model calls `todo_write` through the real agent loop; the `todo/write` event lands and a second call replaces it.
- **`session/load` replay** — a persisted `todo/write` re-emits the `plan` update when a fresh ACP bridge loads the session.
- **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot expected output gains the `plan` notification and the log event.
- **Resume/replay** — a persisted `todo/write` folds back into the current task list.
- **With-key e2e + snapshots** — a real prompt induces `todo_write`; assembled snapshots pin the log event and interactive rendering.
## Alternatives considered
- **In-memory `ctx.todos` service** — would reinvent durability, replay, and `session/load` reconstruction the log gives for free.
- **In-memory `ctx.todos` service** — would reinvent durability, replay, and resume reconstruction the log gives for free.
- **Per-item delta protocol** — only needed for a shared multi-owner list, which is out of scope; whole-list replace is simpler and matches the references.
- **Tool in `core/`** — `todo_write` is an extension tool registering on `ctx.tools`, not part of the spine; it lives in its own `packages/todo/` group like other tool families.
## Consequences
The todo list is durable, replayable session state: a persisted `todo/write` re-emits the editor's `plan` update on `session/load`, and the log — not plugin memory — is the single source of truth. Whole-list replace means one tool call per update with last-write-wins; there is no delta protocol to reconcile. The event stays off the surface, so a todo update never perturbs the derived model history — the model sees only its own tool call and result.
The todo list is durable, replayable session state: an interactive host re-derives it from the latest persisted `todo/write`, and the log — not plugin memory — is the single source of truth. Whole-list replace means one tool call per update with last-write-wins; there is no delta protocol to reconcile. The event stays off the model surface, so a todo update never perturbs derived model history — the model sees only its own tool call and result.
@@ -6,31 +6,27 @@ Status: implemented
## 问题
harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为人类提供实时进度清单。ACPAgent Client Protocol)协议原生支持 `plan` sessionUpdate,编辑器(Zed)已能渲染它,但 bridge 从未发出过。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。
harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结构化的任务列表。todo 列表有两个同等重要的用途:引导模型规划多步骤工作并保持当前活跃任务明确(最多一个活跃,有剩余工作时恰好一个);同时为交互式宿主提供实时进度清单。调研的所有参考编码 agent(智能体)(claude-code、opencode、codex、oh-my-pi、pi)都提供了某种形式的此功能;本 harness 此前没有。
## 决策
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染;ACP bridge 将列表映射为 `plan` sessionUpdate
新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。交互式宿主从持久事件渲染;TUI 直接折叠它,而[仅面向自动化的 ACPAgent Client Protocol)桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意省略 todo 展示
### 整列表替换,三态 status
模型每次调用发送完整列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同采用的形状,也是模型训练最多的形状——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`与 codex `update_plan` 相同的三元组,且关键的是**与 ACP `PlanEntryStatus` 完全一致**,bridge 因此可以 1:1 映射,无需有损转换
模型每次调用发送完整列表;新列表替换旧列表(回放时 last-write-wins)。这是 claude-code V1、opencode 和 codex `update_plan` 共同采用的形状,也是模型训练最多的形状——没有逐项 id,没有 delta 协议。`status` 恰好是 `pending | in_progress | completed`与 codex `update_plan` 相同的三元组;在 bridge 还把 todo 列表投影为 `plan` 更新时,它也与 ACP `PlanEntryStatus` 1:1 对应,该映射已随[仅面向自动化的 ACP 契约](../simplification/2026-07-23-acp-automation-only-protocol.md)退役
### 状态在会话日志上,而非服务
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和恢复重建:重新打开的会话从最新的 `todo/write` 重新推导当前列表,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。
### 不是 surface 事件
`todo/write` 被有意排除在 `SurfaceEventType` 之外。surface 是产出 LLM 消息历史(`deriveMessages()`)的投影;todo write 不产生对话消息。因此它不携带 `surfaceOp`,不加入有序 surface,不进入 `deriveMessages()`——它是持久、可回放的 *UI* 状态,与对话并行传输但不属于对话的一部分。(dev-mode 不变式仍要求它位于一个打开的轮次内,而它始终如此:它在工具调用的步骤中途追加。)
### Priority 仅在 ACP 边界合成
ACP 的 `PlanEntry` 要求 `content` + `priority` + `status`,但 `TodoItem` 没有 priority——模型从不推理它。与其在 schema 中增加一个模型每次都必须提供的字段,bridge 在构建 `plan` 时为每条条目合成常量 `priority: 'medium'`。Priority 是 ACP 协议格式(wire format)的要求,不是 harness 概念,因此它恰好存在于需要它的边界上。
### 相比 claude-code V1 舍弃的字段:`activeForm`、id、priority
claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识;不要 priority——见上文。每舍弃一个字段,模型每次调用就少产出一项。
claude-code V1 的条目是 `{ content, status, activeForm }`;后来(V2)增加了 id、依赖和所有权——但仅为支持 agent *集群*(磁盘持久、锁保护、逐项变更)。本工具将条目保持在最小集:`{ content, status }`。不要 `activeForm`(现在进行时标签)——UI 直接展示 `content`;不要 id——整列表替换不需要稳定标识;不要 priority——它只曾是 ACP `PlanEntry` 的协议格式(wire format)要求,在 bridge 边界合成为常量而非建模,并已随该投影一起离开。每舍弃一个字段,模型每次调用就少产出一项。
### 单一所有者——无集群机制(YAGNI)
@@ -47,18 +43,18 @@ schema 强制 type/required/enum。在此之上,`execute` 拒绝为空或重
## 测试
四个层级,预先设计:
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR(热模块替换)安全性);ACP `todosToPlan` 映射;stdio 渲染分支
- **单元测试**——会话事件(append/snapshot-clone/last-write-wins/not-on-surface);工具(schema 形状、通过真实 `ctx.tools.execute` 的参数校验、值校验、事件追加与替换、非 agent 拒绝、`presentCall`、HMR(热模块替换)安全性);以及 TUI 折叠
- **真实 Loader 路径**——插件通过 `Loader.unwrapExports` 运行,断言命名空间导出形状存活(它有 `inject`,因此一个意外的 default 导出会在加载时崩溃——postmortem/0001)。
- **全循环集成**——一个脚本化的 mock 模型通过真实 agent loop(智能体循环)调用 `todo_write``todo/write` 事件落地,第二次调用替换它。
- **`session/load` 回放**——持久化的 `todo/write` 在新的 ACP bridge 加载会话时重新发出 `plan` 更新
- **带密钥 e2e + 快照**——真实提示词诱导一次 `todo_write`快照预期输出获得 `plan` 通知和日志事件
- **恢复/回放**——持久化的 `todo/write` 折叠回当前任务列表
- **带密钥 e2e + 快照**——真实提示词诱导 `todo_write`组装后的快照固定日志事件和交互式渲染
## 曾考虑的替代方案
- **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和 `session/load` 重建。
- **内存中的 `ctx.todos` 服务**——需要重新发明日志免费提供的持久性、回放和恢复重建。
- **逐项 delta 协议**——仅在共享多所有者列表时需要,超出当前范围;整列表替换更简单,且与参考实现一致。
- **工具放在 `core/` 中**——`todo_write` 是注册在 `ctx.tools` 上的扩展工具,不属于主干;它像其他工具族一样位于自己的 `packages/todo/` 分组中。
## 后果
todo 列表是持久、可回放的会话状态:持久化的 `todo/write` `session/load` 时重新发出编辑器的 `plan` 更新,日志(而非插件内存)是唯一真源。整列表替换意味着每次更新一次工具调用,last-write-wins;没有需要协调的 delta 协议。事件不进入 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
todo 列表是持久、可回放的会话状态:交互式宿主从最新持久化的 `todo/write` 重新推导它,日志(而非插件内存)是唯一真源。整列表替换意味着每次更新一次工具调用,last-write-wins;没有需要协调的 delta 协议。事件不进入模型 surface,因此 todo 更新永远不会扰动推导出的模型历史——模型只看到自己的工具调用和结果。
@@ -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
2026-06-30-hook-bridges.md: b6b0894e5551563187b1631e8c641e326fa166b0
2026-06-30-hook-bridges.zh.md: 5f3adef958738d9d23c159c388be35b89f321828
2026-06-30-hook-bridges.md: c207b6901155925215548676364e903f5de2f29b
2026-06-30-hook-bridges.zh.md: d396279d7ed1991536da2cea39e2aec5e50960c2
@@ -31,7 +31,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
| `subagent/start` (emit) | additionalContext → inject into a live in-process child; a remote child has no local injection target | unsupported by this bridge |
| `subagent/end` (emit) | observe-only | unsupported by this bridge |
The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). A composed ACP answerer prompts the owning editor session and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`.
The CC bridge's `ask` result is a real permission path, not a terminal bridge decision: `dsh-tools` resolves it through the optional [approval seam](2026-07-06-approval-seam.md). An ACP automation client may answer the owning session's one-shot machine-policy request and `allowed-once` proceeds; without an ApprovalService or answerer, the call fails closed to `deny`.
### Context source is always the plugin (the mislabel guard)
@@ -55,7 +55,7 @@ Hooks run in the agent's session workspace, so relative paths target the user's
## Deferred compatibility gaps
- **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + ACP/tool-bash presentation, so an honest rewrite is a design unit, not a field.
- **Tool-input rewrite.** A CC/Codex `updatedInput` is logged + warned, not honored — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)), because the pre-execution args are read by `tool/call` audit + `assistant/message` history + tool presentation, so an honest rewrite is a design unit, not a field.
- **Stop loop-guard** (`TODO(stop-loop-guard)`). Claude Code supplies `stop_hook_active` and overrides a hook after eight consecutive blocks; Codex supplies `stop_hook_active` but documents no equivalent cap. Both bridges always report `false`, so a Stop hook that unconditionally blocks force-continues every step — a hook author must self-limit until state tracking lands.
- **Hook `continue:false` (hard halt).** A hook can ask to halt the whole run (CC/Codex `continue:false`); the shared merge folds it into `MergedHookOutcome.stop`/`stopReason`, but no bridge acts on it (`TODO(hook-continue-false)`) — the interception seams have no "hard-halt the agent" primitive yet (a Decision blocks/steers a single point, not the run). Deferred with the loop-guard work; the halt request is recorded in the `hook/result` log, and the hook keeps its per-point effect (decision/context) meanwhile.
- **Config discovery.** The path is explicit in `cordis.yml` and process-level (see above); the full multi-layer CC/Codex precedence walk, per-session project-local discovery, and the trust/hash model are not reimplemented (`TODO(per-session-hook-config)`).
@@ -31,7 +31,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](
| `subagent/start`emit | additionalContext → 注入到存活的进程内 subagent;远程 subagent 无本地注入目标 | 本桥接不支持 |
| `subagent/end`(emit) | 仅观察 | 本桥接不支持 |
CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。组合式 ACP 应答器向拥有该会话的编辑器会话发起提示`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。
CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决策:`dsh-tools` 通过可选的[审批 seam](2026-07-06-approval-seam.md) 来解析它。ACP 自动化客户端可以应答所属会话的一次性机器策略请求`allowed-once` 后继续执行;如果没有 ApprovalService 或应答器,调用以 `deny` 安全关闭。
### 上下文来源始终是插件(误标签防护)
@@ -55,7 +55,7 @@ Claude Code 始终导出 `CLAUDE_PROJECT_DIR`,常见的未修改钩子引用 `
## 推迟的兼容性缺口
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和 ACP/tool-bash 展示共同读取,诚实的重写是一个设计单元,而非一个字段。
- **工具输入重写。** CC/Codex 的 `updatedInput` 被记录日志并发出警告,但不予执行——输入重写是一个推迟的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)),因为 pre-execution 参数被 `tool/call` 审计、`assistant/message` 历史和工具展示共同读取,诚实的重写是一个设计单元,而非一个字段。
- **Stop 循环防护**`TODO(stop-loop-guard)`)。Claude Code 提供 `stop_hook_active` 并在连续八次阻塞后覆盖钩子;Codex 提供 `stop_hook_active` 但未记录等效上限。两个桥接始终报告 `false`,因此一个无条件阻塞的 Stop 钩子会在每一步强制继续——在状态追踪落地之前,钩子作者必须自行限制。
- **钩子 `continue:false`(硬停止)。** 钩子可以请求终止整个运行(CC/Codex `continue:false`);共享合并将其折叠为 `MergedHookOutcome.stop`/`stopReason`,但没有桥接对其采取行动(`TODO(hook-continue-false)`)——拦截 seam 尚无「硬停止 agent」原语(Decision 阻塞/引导的是单个点,而非整个运行)。与循环防护工作一同推迟;停止请求记录在 `hook/result` 日志中,钩子在此期间保留其逐点效果(决策/上下文)。
- **配置发现。** 路径在 `cordis.yml` 中显式指定且为进程级(见上文);完整的多层 CC/Codex 优先级遍历、按会话的项目本地发现以及信任/hash 模型未被重新实现(`TODO(per-session-hook-config)`)。
@@ -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
2026-06-30-interception-seams.md: 8af489d3f575dd457e1981e433330f08cefa2043
2026-06-30-interception-seams.zh.md: 3886ed21504abfe14a2ccb344d7aa0d3f8d25064
2026-06-30-interception-seams.md: 81799f4c6e3e7a4c6b9605cd97f5728b99d11995
2026-06-30-interception-seams.zh.md: 65ae16842c632641e7ac65908162f4784dc6e1e0
@@ -45,7 +45,7 @@ Core dispatch and the tool body sit inside normalization boundaries, so tool, li
### Pre-tool input rewrite is a separate consistency decision
`PreToolDecision` cannot rewrite arguments. History and the audit call are logged before execution, and ACP presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
`PreToolDecision` cannot rewrite arguments. History and the audit call are logged before execution, and UI presentation reads the same input, so the registry seals arguments before policy. A valid rewrite must update history, audit, presentation, and execution before identity is created; that contract belongs to the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md).
### Boundaries
@@ -45,7 +45,7 @@ harness 需要一套钩子子系统:用户像 Claude CodeCC)和 Codex 那
### 工具执行前输入重写是一个独立的一致性决策
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,ACP 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
`PreToolDecision` 不能重写参数。历史和审计调用在执行前记录,UI 展示读取相同的输入,因此注册表在策略之前封存参数。有效的重写必须在身份创建之前同时更新历史、审计、展示和执行;该契约属于[输入重写提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)。
### 边界
@@ -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
2026-06-30-session-store-fork-api.md: 4e4ea7f1d3aa3290bdb21999a292dbdfd016302e
2026-06-30-session-store-fork-api.zh.md: d17daa464ad59c6b5ac4d5041b4af30c44321fa7
2026-06-30-session-store-fork-api.md: 13d411e915b2e34b15a12e632f1a5e047f4aeedc
2026-06-30-session-store-fork-api.zh.md: bcf15eb581af3993ed2d71a2b7dc604faa6e4433
@@ -40,4 +40,4 @@ An empty prefix is forkable; any non-empty boundary must be a safe existing sequ
The public surface stays small and discoverable: live session branching is part of `ctx.sessions`, next to `create({ seed })`, rather than a standalone service or a two-step helper pair. Persistence continues to work through existing `session/created` and `session/flush` behavior: a forked child starts life with seeded events, so existing backends persist that seed once and preserve `parentSession` / `seedLength` in the header.
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has transcript/snapshot coverage; this Agent Note adds no editor-facing updates, so no ACP snapshot is required now. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage.
The v1 scope still excludes ACP `session/fork`, unloaded persisted-session forking, model-facing tools, and subagent refactors. If a future ACP method is added, it should advertise the capability only after it has protocol and snapshot coverage; this Agent Note adds no ACP wire behavior, so no ACP snapshot is required. Fork-child replay remains covered by the existing [seed-boundary testing Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md), while this API gets focused `dsh-session` unit tests plus JSONL persistence coverage.
@@ -40,4 +40,4 @@ class SessionStore extends Service {
公开接口保持精简且易于发现:活跃会话分支是 `ctx.sessions` 的一部分,紧邻 `create({ seed })`,而非一个独立服务或一对两步辅助函数。持久化继续通过现有的 `session/created` 和 `session/flush` 行为运作:fork 出的子会话以种子事件开始生命,因此现有后端只需持久化该种子一次,并在 header 中保存 `parentSession``seedLength`。
v1 范围仍然排除 ACPAgent Client Protocol `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备 transcript(文本记录)/快照覆盖后才广播该能力;本 Agent Note 不添加面向编辑器的更新,因此当前不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
v1 范围仍然排除 ACPAgent Client Protocol `session/fork`、对未加载的已持久化会话的 fork、面向模型的工具,以及 subagent 重构。如果未来添加 ACP 方法,应在具备协议与快照覆盖后才广播该能力;本 Agent Note 不添加任何 ACP 协议行为,因此不需要 ACP 快照。fork 子会话的回放仍由现有的[种子边界测试 Agent Note](../testing/2026-06-22-fork-child-replay-seed-boundary.md) 覆盖,而本 API 则获得专门的 `dsh-session` 单元测试加 JSONL 持久化覆盖。
@@ -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
2026-07-05-dynamic-workflows.md: 4c8606fb617b3fb6b2e8ad9c35d77c585b1fe8b1
2026-07-05-dynamic-workflows.zh.md: 757eafb5d21f42b2ca529ebeb6b16eb8205e823d
2026-07-05-dynamic-workflows.md: bba62098c66477a3f1929f9029e81c645bfc4d41
2026-07-05-dynamic-workflows.zh.md: 6aa1ce63f0edf9dbf296d12d3bc0c62594fa33a6
@@ -60,7 +60,7 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea
- **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred).
- **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here.
- **Engine hardening beyond worker threads**: an isolated-vm or separate-process engine behind the same seam (actual sandboxing; memory limits).
- **ACP progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it.
- **Human-interface progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it.
- **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`).
## Alternatives considered
@@ -60,7 +60,7 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都以命名延迟的消息大声拒绝)。
- **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。
- **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
- **ACP 进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。
- **面向人类界面的进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。
- **ACP 后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。
## 曾考虑的替代方案
@@ -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
2026-07-05-skill-system.md: 3c328adf09ab0f6ad3388a01bac3661d45c9247e
2026-07-05-skill-system.zh.md: ba30a3735b73b00afb46baa24f969fd687a759b5
2026-07-05-skill-system.md: 95772067b3b224f15fd290876c297eccbe5ab97e
2026-07-05-skill-system.zh.md: 589410076b6da1b108b01f55bf217001b2a8404f
@@ -12,7 +12,7 @@ DeepSeek Harness uses the same primitive so project-specific review, plugin-auth
## Decision
`@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the session-prefix catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so stdio and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners.
`@deepseek-ai/dsh-skill` is the pure provider registry (`ctx.skills`), `@deepseek-ai/dsh-skill-local` is the shipped local filesystem provider, and `@deepseek-ai/dsh-tool-skill` owns the session-prefix catalog and model-facing loader tool. `dsh-agent-spine-demo` loads the registry, local provider, and consumer by default so TUI, headless, and ACP apps get the same behavior while embedded or remote providers contribute skills without changing the registry or consumer. Its `skills` config forwards `registry`, `local`, and `tool` branches to those owners.
Provider plugins register synchronously during `apply()`. Provider membership is direct effect-owned state: registration and disposal invalidate completed catalogs synchronously, and discovery reads the current provider map on demand rather than observing registry-change events. Provider catalogs return ranked candidates from awaited `list()` calls, where remote providers perform initialization, authentication, and discovery while honoring the lookup abort signal. The registry validates each candidate, resolves same-name skills first-wins by rank, provider registration order, and provider-local order, then sorts summaries by skill name for deterministic consumers. It caches only completed catalog snapshots and retries when a provider/runtime revision changes during discovery, so an unload cannot freeze a stale, unresolvable skill into a session prefix. Runtime `ctx.skills.register(...)` remains a convenience for embedded in-process skills and uses project-over-user priority; `runtime` is reserved as the registry-owned provider name.
@@ -32,7 +32,7 @@ The data structures and catalog/tool contract are documented in [skills.md](../.
**Inject full skill bodies into every system prompt.** Rejected because it destroys progressive disclosure and makes every request pay for instructions that may not apply.
**Expose skills only as slash commands.** Rejected because model-initiated loading is the core capability; slash/ACP command advertisement does not change discovery.
**Expose skills only as slash commands.** Rejected because model-initiated loading is the core capability; human command advertisement does not change discovery.
**Put local filesystem scanning directly inside `ctx.skills`.** Rejected because coding agents, web agents, and future plugin ecosystems need different skill sources. A provider registry mirrors the subagent seam: the registry owns conflict resolution and consumers, while implementations own loading.
@@ -12,7 +12,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和
## 决策
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 stdio 与 ACPAgent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry``local``tool` 分支分别转发给对应的所有者。
`@deepseek-ai/dsh-skill` 是纯提供方注册表(`ctx.skills`),`@deepseek-ai/dsh-skill-local` 是随附的本地文件系统提供方,`@deepseek-ai/dsh-tool-skill` 负责会话前缀目录与面向模型的 loader 工具。`dsh-agent-spine-demo` 默认加载注册表、本地提供方和消费方,使 TUI、headless 与 ACPAgent Client Protocol)应用获得相同行为,同时嵌入式或远程提供方可在不修改注册表或消费方的前提下贡献 skill。其 `skills` 配置将 `registry``local``tool` 分支分别转发给对应的所有者。
提供方插件在 `apply()` 期间同步注册。提供方成员资格是由直接 effect 持有的状态:注册与 dispose(资源释放)同步地使已完成的目录失效,发现操作按需读取当前提供方映射而非监听注册表变更事件。提供方目录从等待的 `list()` 调用返回排序后的候选项,远程提供方在此过程中执行初始化、认证和发现,同时遵守查找的 abort 信号。注册表校验每个候选项,按排名、提供方注册顺序和提供方内部顺序以先到先得方式解决同名 skill 冲突,然后按 skill 名称排序摘要以保证消费方获得确定性结果。它仅缓存已完成的目录快照,并在发现过程中提供方/运行时修订版本发生变化时重试,因此卸载操作不会将一个陈旧且不可解析的 skill 冻结到会话前缀中。运行时 `ctx.skills.register(...)` 仍作为嵌入式进程内 skill 的便捷方式保留,使用 project 优先于 user 的优先级;`runtime` 保留为注册表拥有的提供方名称。
@@ -32,7 +32,7 @@ DeepSeek Harness 使用同一原语,使项目特定的评审、插件编写和
**将完整 skill 正文注入每条系统提示词。** 否决,因为这破坏了渐进式披露,使每个请求都为可能不适用的指令付出代价。
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;斜杠/ACP 命令广播不改变发现机制。
**仅以斜杠命令暴露 skill。** 否决,因为模型主动加载是核心能力;面向人类的命令广播不改变发现机制。
**将本地文件系统扫描直接放入 `ctx.skills`。** 否决,因为编码 agent、Web agent 和未来的插件生态需要不同的 skill 来源。提供方注册表与 subagent seam 镜像:注册表拥有冲突解决和消费方,实现拥有加载。
@@ -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
2026-07-06-approval-seam.md: e28587e0185530164f300562f7dd5e57255ac515
2026-07-06-approval-seam.zh.md: 94ffb330cd36ae88975e8ae9d579b3b4606a5c25
2026-07-06-approval-seam.md: 852108f22be22eeba4578032924adb546ee10985
2026-07-06-approval-seam.zh.md: 9a08f333e0859e6d71f40b039f4b441028c38dc3
@@ -6,13 +6,13 @@ English | [中文](2026-07-06-approval-seam.zh.md)
## Problem
Two callers need to put one question — "may this specific action proceed?" — to a human: `tools/pre-execute`'s `ask` decision (including the Claude-Code hook bridge's `permissionDecision: ask`) and the [sandbox Agent Note](2026-07-06-sandbox.md)'s post-denial one-shot escalation retry. A shared seam keeps them from inventing separate outcome vocabularies, UI routing, cancellation, and audit trails, while guaranteeing that a deployment with no UI can never grant an unanswerable request.
Two callers need one closed decision — "may this specific action proceed?": `tools/pre-execute`'s `ask` decision (including the Claude-Code hook bridge's `permissionDecision: ask`) and the [sandbox Agent Note](2026-07-06-sandbox.md)'s post-denial one-shot escalation retry. A shared seam keeps them from inventing separate outcome vocabularies, channel routing, cancellation, and audit trails, while guaranteeing that a deployment with no answerer can never grant an unanswerable request. The answerer may be an interactive host or an automated controller.
The routing problem is ownership: an approval prompt must reach the editor session that owns the asking agent (the ACP bridge multiplexes N sessions over one connection), fail closed for agents nobody owns (in-process subagents, tests), and stay out of deployments that compose no UI (headless, CI).
The routing problem is ownership: a permission request must reach the channel that owns the asking agent, fail closed for agents nobody owns, and stay out of deployments that compose no answerer.
## Decision
One package, `dsh-user-approval` (`packages/ui/user-approval`), owning the vocabulary and the `ctx.approval` service — the MECHANISM. The POLICY — who answers, and whether a session is asked at all — lives outside it: answerers are `approval/request` waterfall listeners registered by the plugins that own the channel (the ACP bridge; future terminal UIs; test scripts), and a per-session policy tier can decide before any human is involved. Consumers (`dsh-tools`' ask routing, the sandbox escalation gate) resolve a question to a closed outcome and derive their own tool results from it. Deliberately ONE package, not the capability-seam three (see Alternatives).
One package, `dsh-user-approval` (`packages/ui/user-approval`), owns the vocabulary and the `ctx.approval` service — the mechanism. The policy — who answers, and whether a session is asked at all — lives outside it: answerers are `approval/request` waterfall listeners registered by channel-owning plugins (the ACP bridge, host adapters, and test scripts), and a per-session policy tier can decide before a channel is involved. Consumers (`dsh-tools`' ask routing and the sandbox escalation gate) resolve a question to a closed outcome and derive their own tool results from it. This is deliberately one package, not the capability-seam three (see Alternatives).
### How a deployment uses it
@@ -25,11 +25,11 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou
# policy: never # deployment default for sessions without an override; 'ask' when omitted
```
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws.
The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its [automation-only bridge](../simplification/2026-07-23-acp-automation-only-protocol.md) registers an answerer that sends `session/request_permission` to the owning client with the exact tool-call id and one-shot allow/reject options. `policy: never` is the unattended stance — every ask auto-rejects deterministically and is stated in the system prompt. `policy` is validated against the closed list at plugin load; anything else throws.
What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision.
One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once:
One ask under this composition, from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, and the automation client selects Allow once:
```
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
@@ -40,12 +40,12 @@ approval/asked {"toolName": "bash", "callId": "call_00_…",
→ session/request_permission {"toolCall": {"toolCallId": "call_00_…"},
"options": [{"optionId": "allow-once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "reject-once", "name": "Reject", "kind": "reject_once"}]}
← the user picks "Allow once" on the prompt the editor attaches to the streamed bash call
← the client selects "Allow once"
approval/decided {"outcome": "allowed-once"}
tool/result "escalated" — this one call ran under the wider mode; the grant died with it
```
The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothing executes, and the model's result carries the asker's verbatim fail-closed text (`the user rejected escalating this command to "workspace-write"`). A hook's `permissionDecision: ask` rides the identical wire; only the asker and its deny texts differ (§ Ask routing in dsh-tools). Headless, the same request skips the prompt entirely and settles `unavailable`.
The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothing executes, and the model's result carries the asker's verbatim fail-closed text (`the user rejected escalating this command to "workspace-write"`). A hook's `permissionDecision: ask` rides the identical wire; only the asker and its deny texts differ (§ Ask routing in dsh-tools). Without an answerer, the same request settles `unavailable`.
### Design detail
@@ -55,11 +55,11 @@ After validation and a successful `approval/asked` append, the service resolves
Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates.
`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`.
`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments.
#### Ask routing in dsh-tools
`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner.
`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a channel owner.
#### The per-session policy tier
@@ -67,9 +67,9 @@ The seam also owns the session-scoped `'ask' | 'never'` policy described by [the
#### The ACP answerer
The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all.
The ACP bridge answers only for an exact agent object owned by its session map. It sends `session/request_permission` with the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. This channel is machine policy between an automated client and its agent, not ACP presentation.
The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md).
#### Audit, and what the model sees
@@ -88,13 +88,13 @@ Snapshots record allowed and rejected sandbox escalation through `session/reques
## Deferred
- **`allow_always` grant storage** — honoring a persistent grant means designing storage, scope identity (call? path? prefix? session? time window?), and revocation; until designed, only the one-shot options are advertised ([the sandbox Agent Note](2026-07-06-sandbox.md) § Escalation records the open scope question).
- **A recorded hook-driven `ask` through a composed answerer** — the human-prompt wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier.
- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own `permission` requests; surfacing them to the parent's editor is its own design.
- **A recorded hook-driven `ask` through a composed answerer** — the permission wire is recorded through the sandbox example's escalation branches. The hook matrix's `hook-cc-pretool-ask` pins the no-ApprovalService fallback denial, while the hook-producer-plus-answerer composition remains on the unit tier.
- **Routing a child agent's approvals to the parent session** — `subagent-acp`'s child auto-answers its own permission requests; delegating them to the parent controller is its own design.
## Alternatives considered
- **A single registered provider instead of waterfall listeners** — rejected: a `registerProvider()` surface forces every composition question — allowlist pre-filters, external hook deciders, scripted test answers, a policy gate in front of a human — inside one provider implementation. The waterfall gets composition, fail-closed absence, and HMR disposal from machinery the runtime already has; the seam's JSDoc pins the single-decision-slot convention instead of inventing a provider registry.
- **An inline `tools/pre-execute` permission gate in the ACP bridge** — rejected: prompting for every bridge-owned call hardwires the asking POLICY into the UI plugin, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hook-produced `ask` decisions without a shared mechanism.
- **An inline `tools/pre-execute` permission gate in the ACP bridge** — rejected: prompting for every bridge-owned call hardwires the asking policy into the transport, cannot serve a second asker (sandbox escalation happens after execution starts, with no pre-execute moment), and leaves hook-produced `ask` decisions without a shared mechanism.
- **The generic user-interaction seam (`ctx.userInteraction`)** — rejected as the approval mechanism: the two share a skeleton (route by agent, block for a human, handle absence), but approval's contract is narrower in every dimension that matters: a closed outcome vocabulary instead of free text, a protocol-native prompt attached to a tool call instead of a generic form, mandatory fail-closed absence, and audit events. Approval therefore does not ride the shipped `packages/ui/user-interaction` / `ask_user_question` elicitation path — an elicitation form is not a permission prompt, and a free-text answer is not a closed outcome; sharing provider plumbing stays open if the two ever converge.
- **Static optional injection in `dsh-tools`** — rejected: the vendored cordis `Inject` type has no optional flag — the object form maps service names to intercept config, and a declared inject gates the fiber. `ctx.get('approval')` is the documented opportunistic-consumption pattern (the `tool-bash` owner-token lookup, the loop's persistence probe), reads presence per call, and degrades correctly across HMR without extra machinery.
- **The capability-seam three-package split** — rejected: interface/implementation/consumer fits a seam whose implementation is swappable (bash-local vs bash-sandbox). Here the service body is fixed mechanism and the variable part is listeners that live with their owners — splitting would manufacture an implementation package with nothing in it ("don't split preemptively").
@@ -107,13 +107,13 @@ The implemented contract is pinned by the suites in Testing:
- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting.
- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed.
- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject.
- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events.
- ACP ownership keeps decisions inside their session, while a deployment without the service emits no request or audit events.
Costs and accepted limits:
- **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have.
- **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it.
- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract.
- **Ownership keys on `Agent` object identity.** The answerer resolves the session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed, and would need a different ownership contract.
## FAQ
@@ -123,10 +123,10 @@ Costs and accepted limits:
- **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt.
- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two.
- **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant.
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred).
- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred).
- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair.
- **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state.
- **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own.
- **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state.
- **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam.
## Prior art
@@ -135,5 +135,5 @@ In-repo precedents this design copies or contrasts with:
- The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses.
- `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges Agent Note](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer.
- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services.
- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the forward session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements.
- The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it.
@@ -6,13 +6,13 @@ Status: implemented
## 问题
两个调用方需要向人类提出同一个问题——「这个具体操作可以继续吗?」:`tools/pre-execute``ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 Agent Note](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、UI 路由、取消机制和审计轨迹,同时保证没有 UI 的部署永远不会批准一个无法应答的请求。
两个调用方需要同一个封闭决策——「这个具体操作可以继续吗?」:`tools/pre-execute``ask` 决策(包括 Claude-Code 钩子桥的 `permissionDecision: ask`)以及[沙箱 Agent Note](2026-07-06-sandbox.md) 中拒绝后的一次性升级重试。一个共享的 seam 使它们无需各自发明独立的结果词汇、通道路由、取消机制和审计轨迹,同时保证没有应答者的部署永远不会批准一个无法应答的请求。应答者可以是交互式宿主,也可以是自动化控制器。
路由问题的核心是归属:审批提示必须到达拥有发起请求的 agent(智能体)的编辑器会话(ACPAgent Client Protocol)桥在一条连接上多路复用 N 个会话),对无人拥有的 agent(进程内 subagent、测试)失败关闭,并且不侵入没有组合 UI 的部署(headless、CI
路由问题的核心是归属:权限请求必须到达拥有发起请求的 agent(智能体)的通道,对无人拥有的 agent 失败关闭,并且不侵入没有组合应答者的部署
## 决策
一个包 `dsh-user-approval``packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACP 桥、未来的终端 UI、测试脚本),而每会话的策略层可以在任何人类介入之前做出决定。消费方(`dsh-tools` 的 ask 路由沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。
一个包 `dsh-user-approval``packages/ui/user-approval`),拥有词汇表和 `ctx.approval` 服务——即机制。策略——谁来应答、某个会话是否需要被询问——不在其中:应答者是 `approval/request` waterfall 监听器,由拥有通道的插件注册(ACPAgent Client Protocol)桥、宿主适配器、测试脚本),而每会话的策略层可以在任何通道介入之前做出决定。消费方(`dsh-tools` 的 ask 路由沙箱升级门禁)将问题解析为一个封闭结果,并从中派生各自的工具结果。刻意设计为一个包,而非能力 seam 的三包拆分(见「替代方案」)。
### 部署如何使用它
@@ -25,11 +25,11 @@ Status: implemented
# policy: never # deployment default for sessions without an override; 'ask' when omitted
```
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其桥注册一个应答者,通过 `session/request_permission` 向拥有该会话的编辑器发出提示,于是钩子的 `ask` 或升级请求会以一次性 Allow/Reject 提示的形式呈现,附着在已流式输出的工具调用上`policy: never` 是无人值守姿态:每次 ask 确定性地自动拒绝,在系统提示词中声明,无人类参与`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
仅有这条条目只提供机制,不提供通道:没有组合应答者时,每次 ask 都解析为 `unavailable`,发起请求的工具调用被拒绝——失败关闭无需配置。组合 ACP 应用(`@deepseek-ai/dsh-acp-demo`,如 [acp-agent 示例的默认树](../../../../examples/acp-agent/README.md))即可闭环:其[仅面向自动化的桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)注册一个应答者,向拥有该会话的客户端发送 `session/request_permission`,携带精确的工具调用 id 和一次性 allow/reject 选项`policy: never` 是无人值守姿态:每次 ask 确定性地自动拒绝,在系统提示词中声明。`policy` 在插件加载时对照封闭列表校验;非法值直接抛异常。
组合部署的可观测行为:`allowed-once` 仅允许该次调用继续;拒绝、关闭和通道缺失以三种不同原因拒绝,模型可以区分;轮次内成功的请求会在发起请求的 agent 的会话日志上落一对持久的 `approval/asked`/`approval/decided` 事件;授权不会在发起请求的调用结束后继续存在。空闲时的请求或审计追加失败会拒绝,而不会返回未经审计的决策。
以下是该组合下的一次 ask逐字取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,桥向拥有该会话的编辑器发出提示,用户点击 Allow once
以下是该组合下的一次 ask,取自沙箱示例录制的 `escalation-approved` 场景——模型请求沙箱升级,门禁发起 ask,自动化客户端选择 Allow once
```
tool/call bash {"command": "printf 'escalated\n' > escalated.txt && cat escalated.txt",
@@ -40,12 +40,12 @@ approval/asked {"toolName": "bash", "callId": "call_00_…",
→ session/request_permission {"toolCall": {"toolCallId": "call_00_…"},
"options": [{"optionId": "allow-once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "reject-once", "name": "Reject", "kind": "reject_once"}]}
← the user picks "Allow once" on the prompt the editor attaches to the streamed bash call
← the client selects "Allow once"
approval/decided {"outcome": "allowed-once"}
tool/result "escalated" — this one call ran under the wider mode; the grant died with it
```
`escalation-rejected` 孪生场景以 `{"outcome": "rejected"}` 结束:不执行任何操作,模型的结果携带发起方的逐字失败关闭文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。在 headless 环境下,同一请求完全跳过提示,直接结算为 `unavailable`
`escalation-rejected` 孪生场景以 `{"outcome": "rejected"}` 结束:不执行任何操作,模型的结果携带发起方的逐字失败关闭文本(`the user rejected escalating this command to "workspace-write"`)。钩子的 `permissionDecision: ask` 走完全相同的协议;只有发起方和拒绝文本不同(§ dsh-tools 中的 Ask 路由)。没有应答者时,同一请求直接结算为 `unavailable`
### 设计细节
@@ -55,11 +55,11 @@ tool/result "escalated" — this one call ran under the wider mode; the gra
应答者是 `approval/request` waterfall 监听器。零监听器会一路委派至 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器随其 fiber dispose,因此卸载通道会失败关闭。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。
`ApprovalRequest` 携带发起请求的 `agent``toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`工具参数仍留在 UI 通过 `callId` 引用的、已流式输出的调用上
`ApprovalRequest` 携带发起请求的 `agent``toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `CallId` brand 而不导入依赖本 seam 的 `dsh-tools`通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数
#### dsh-tools 中的 Ask 路由
`ToolRegistry.execute()` 在派发前解析 `ask``allowed-once` 继续执行,而拒绝、取消和通道不可用产生三种不同的拒绝原因。机会性消费 `ctx.get('approval')`,让缺失或未挂载的服务失败关闭而不阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为它既没有审计会话,也没有 UI 所有者。
`ToolRegistry.execute()` 在派发前解析 `ask``allowed-once` 继续执行,而拒绝、取消和通道不可用产生三种不同的拒绝原因。机会性消费 `ctx.get('approval')`,让缺失或未挂载的服务失败关闭而不阻塞注册表 fiber。无 agent 的执行同样失败关闭,因为它既没有审计会话,也没有通道所有者。
#### 每会话策略层
@@ -67,9 +67,9 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 `
#### ACP 应答者
ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它 `session/request_permission` 附着到既有 `callId`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。
ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。
应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,实现了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。
#### 审计,以及模型看到什么
@@ -88,13 +88,13 @@ ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `s
## 延后
- **`allow_always` 授权存储**:兑现持久授权意味着设计存储、作用域标识(调用?路径?前缀?会话?时间窗口?)和撤销;在设计完成之前,只展示一次性选项([沙箱 Agent Note](2026-07-06-sandbox.md) § Escalation 记录了开放的作用域问题)。
- **通过组合应答者录制由钩子驱动的 `ask`**人类提示协议格式已通过沙箱示例的升级分支录制。钩子矩阵中的 `hook-cc-pretool-ask` 固定无 ApprovalService 时的后备拒绝,而钩子生产者与应答者的组合仍留在单元测试层。
- **将子 agent 的审批路由到父会话**:`subagent-acp` 的子侧自动应答自己的 `permission` 请求;将其呈现给父会话的编辑器是独立的设计。
- **通过组合应答者录制由钩子驱动的 `ask`**权限协议格式已通过沙箱示例的升级分支录制。钩子矩阵中的 `hook-cc-pretool-ask` 固定无 ApprovalService 时的后备拒绝,而钩子生产者与应答者的组合仍留在单元测试层。
- **将子 agent 的审批路由到父会话**:`subagent-acp` 的子侧自动应答自己的权限请求;将其委派给父控制器是独立的设计。
## 曾考虑的替代方案
- **单一注册提供方而非 waterfall 监听器**:否决。`registerProvider()` 接口迫使所有组合问题——允许列表预过滤、外部钩子决策者、脚本化测试应答、人类前面的策略门禁——都塞进一个提供方实现。waterfall 从运行时已有的机制中获得组合能力、缺失时失败关闭和 HMR(热模块替换) dispose(资源释放);seam 的 JSDoc 以约定固定单决策槽语义,而非发明一个提供方注册表。
- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求策略硬编码进 UI 插件,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。
- **在 ACP 桥中内联 `tools/pre-execute` 权限门禁**:否决。对桥拥有的每次调用都弹出提示,会将请求策略硬编码进传输层,无法服务第二个发起方(沙箱升级发生在执行开始之后,没有 pre-execute 时刻),且钩子产生的 `ask` 决策没有共享机制。
- **通用用户交互 seam`ctx.userInteraction`)**:否决作为审批机制。二者骨架相似(按 agent 路由、阻塞等待人类、处理缺失),但审批的契约在每个关键维度上都更窄:封闭的结果词汇而非自由文本、附着在工具调用上的协议原生提示而非通用表单、强制的缺失时失败关闭、以及审计事件。因此审批不走已交付的 `packages/ui/user-interaction` / `ask_user_question` 引出路径——引出表单不是权限提示,自由文本应答不是封闭结果;如果二者将来趋同,共享提供方管道仍然开放。
- **`dsh-tools` 中的静态可选注入**:否决。vendor 的 Cordis `Inject` 类型没有 optional 标志——对象形式将服务名映射到拦截配置,声明的 inject 会阻塞 fiber。`ctx.get('approval')` 是文档化的机会性消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测),按调用读取存在性,跨 HMR 正确降级,无需额外机制。
- **能力 seam 的三包拆分**:否决。接口/实现/消费方适合实现可替换的 seambash-local vs bash-sandbox)。此处服务体是固定机制,可变部分是留在各自通道拥有者插件中的监听器——拆分只会制造一个空的实现包(「不要预防性拆分」)。
@@ -107,13 +107,13 @@ ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `s
- `allowed-once` 派发一次操作;其他所有结果都以不同原因拒绝,而 `'never'` 会在提示前拒绝。
- 缺失、外部、无 agent、抛异常、无效或断开连接的应答路径都会失败关闭。
- 成功的请求按精确 agent 归属路由,并追加一对可回放、对模型不可见的审计事件;空闲时和提交前失败的请求会拒绝。
- ACP 归属把提示限制在其会话内,而没有该服务的部署不产生提示或审计事件。
- ACP 归属把决策限制在其会话内,而没有该服务的部署不产生请求或审计事件。
代价与已接受的局限:
- **两个急于决策的应答者竞争同一槽位。** 兄弟插件的监听器顺序不确定,seam 无法仲裁竞争的终端应答者。通过约定缓解(每个部署一个终端应答者;仅对「先决策或委派」门禁使用 `prepend`),而非事件总线不具备的优先级机制。
- **生产环境验证依赖单一组合。** `ask` 有两个生产者家族——钩子桥通过 `tools/pre-execute`,沙箱升级通过自己的门禁——协议格式录制在沙箱示例的快照套件中;因此在更多部署组合它之前,seam 的真实覆盖面就是这一种组合。
- **归属以 `Agent` 对象标识为键。** 应答者先在 `agent.session.id` 处解析正向会话映射记录,再要求该记录拥有精确的 agent 对象;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭——安全,但静默无 UI——届时需要另一种归属契约。
- **归属以 `Agent` 对象标识为键。** 应答者先在 `agent.session.id` 处解析会话映射记录,再要求该记录拥有精确的 agent 对象;当前所有路径在 loop 和各 seam 之间传递同一对象,但未来如果某个边界克隆或代理了 agent,桥会委派并失败关闭届时需要另一种归属契约。
## FAQ
@@ -123,10 +123,10 @@ ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `s
- **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。
- **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。
- **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父会话的编辑器已延后(§ 延后)。
- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。
- **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);系统提示词声明该策略;切换在边界处被叙述;每次成功的自动拒绝都会记录审计对。
- **热重载或 UI 插件在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
- **用户在哪里看到自己在批准什么?** 在工具调用本身:提示通过 `callId` 附着在已流式输出的调用上(包含参数),并添加发起方的人类可读 `reason`;请求本身不携带参数副本
- **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。
- **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。
## 先例
@@ -135,5 +135,5 @@ ACP 桥只应答其正向会话映射所拥有的精确 agent 对象。它把 `s
- `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。
- `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 Agent Note](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。
- [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute``allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`
- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对正向会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。
- 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。
@@ -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
2026-07-06-sandbox.md: c1307a7201ed1bc331a86d4ffee002d69fd1d5e5
2026-07-06-sandbox.zh.md: 4e3d4951f71a602bc78551c90dda04317d0f8284
2026-07-06-sandbox.md: 723ef170188dc11da24e049a1e2838fb240d0a17
2026-07-06-sandbox.zh.md: a8c7743bb3d499fb58f507ea2c202b44efe2311d
@@ -10,7 +10,7 @@ A coding agent needs this product path: bash subprocesses — and the hook comma
The harness is an SDK, so confinement must be a capability developers COMPOSE: whether to sandbox, and which backend per platform, belongs in the leaf `cordis.yml` as a first-class entry — not inside one executor's private machinery. And the first-choice runner, `bwrap`, is unusable on exactly the hosts a sandbox matters most (minimal containers, disabled unprivileged userns, LSMs that deny `mount`), so a fallback runner has to ship with the SDK rather than be assumed on the host.
Confinement alone leaves two gaps. A denial with no escalation path is terminal — the model can only give up, which pressure-cooks operators into configuring `workspace-write` or `danger-full-access` globally and defeats the sandbox. And the model-visible knobs (the sandbox mode, the approval policy) change over an agent's lifetime — an ACP user flips a per-session setting, an operator edits `cordis.yml` while the process is down — while the model must never act on a stale belief about them: what IS the state on every request, what changed while the agent lives, and what changed while nobody was watching all need answers.
Confinement alone leaves two gaps. A denial with no escalation path is terminal — the model can only give up, which pressure-cooks operators into configuring `workspace-write` or `danger-full-access` globally and defeats the sandbox. The sandbox mode and approval policy can also change over an agent's lifetime through deployment config or an optional UI policy control; execution and model-visible policy must derive from the same logged state.
## Decision
@@ -40,13 +40,13 @@ The swap is invisible to every consumer of `ctx.bash`: the bash tools, hook comm
Misconfiguration fails loud: `mode` outside the closed vocabulary is rejected at plugin load, and a host with no usable backend throws the structured `SANDBOX_UNAVAILABLE` — at `confine()` before the command ever spawns — rather than degrading to unconfined execution. `runnerCommand` on `dsh-sandbox-local` is the operator's explicit assertion of a bwrap-compatible runner (chain and probes skipped); it doubles as the deterministic fake-runner seam for keyless tests.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed, ACP exposes one `Permissions` select whose presets write both knob events; unmatched knobs appear as switch-away-only `custom`. Only a switch to the deterministic `'never'` approval policy is stated in the prompt and narrated.
Denied file effects return a `[sandbox: file access denied under <mode> mode]` marker and instructions not to work around the denial. A confining executor adds paired `sandbox_permissions` and `justification` fields for one approved retry that must be strictly wider than the session's effective mode. A grant widens only that retry; rejection executes nothing, returns `the user rejected escalating this command to "<mode>"`, and permits no re-ask. The prompt does not announce sandbox mode, avoiding preemptive refusal. When `dsh-permission` is composed with a UI adapter, one preset selects both knob values; unmatched values fold to `custom`. The [ACP automation composition](../../../../examples/acp-agent/README.md) does not mount that UI service and selects its deployment mode explicitly.
### Design detail
#### Scope grounding
OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the human decision.
OS subprocess confinement applies to the bash executor, including hook commands, and later to ACP subagent children. Filesystem, web, and other tools execute in-process and require policy at their own seams; an argv wrapper cannot confine a function closing over `ctx`. The existing bash request/spec split carries per-call overrides, while `tools/pre-execute` and the approval seam own the one-shot policy decision.
#### The seam: `ctx.sandbox`
@@ -92,7 +92,7 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode —
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
```
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a SESSION-SCOPED override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation (one editor tab's `workspace-write` cannot disturb another's `read-only`) both fall out by construction, and no external config store exists anywhere.
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's own log. Restart immunity (resuming a session replays its log, so overrides come back with zero catch-up machinery) and multi-session isolation both fall out by construction, and no external config store exists anywhere.
**One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages):
@@ -107,9 +107,9 @@ Each owner exports the same three-piece kit: the event declaration, a pure fold
Sandbox mode is not narrated in the prompt; denial results report the mode when it matters, avoiding preemptive refusal based on a standing label. Approval policy is different: only `'never'` is stated because automatic rejection otherwise looks like a user decision. Policy-change notices are coalesced and delivered by the next pre-step, with log-derived fallback after restart. The notice source is inferred from event position: a knob event after the last request header is user-driven; unlogged drift is operator or config driven.
**The editor surface** is protocol-native [Session Config Options](https://agentclientprotocol.com/protocol/session-config-options) — the spec's replacement for session modes (slated for removal in ACP v2), already SDK-typed. When `ctx.permission` is composed, the bridge advertises one `permission` select (category `mode`) in `session/new` and `session/load`; its options are the deployment's preset table, and its `currentValue` is `PermissionService.current()` over the session log plus composition defaults. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy and write through to both domain setters; a knob combination outside the table is reported as switch-away-only `custom`. `session/set_config_option` validates and switches through the permission service, then returns the complete refreshed state (the spec contract).
**The optional UI surface** is `PermissionService`: a deployment-defined preset table whose entries bundle one sandbox mode with one approval policy. The shipped `workspace-write` and `danger-full-access` presets write through to both domain setters; a knob combination outside the table is reported as `custom`. UI adapters may expose that table as a selector. The automation-only ACP transport advertises no configuration selector and mounts no permission-preset service.
**Turn enclosure is the commit boundary.** A switch during an open turn appends immediately. An idle switch remains pending on the bridge record and is appended at the next prompt submission, before assembly or execution; last write wins per knob. Openness comes from log boundaries rather than `agent.status`, and setters do not append from inside a `session/event` listener because that would reorder later observers. Until anchoring, responses overlay the pending value. A crash discards it, and reload returns the durable fold.
**The committed event is the commit boundary.** A runtime switch records its preset and changed knob events on the target session, and every later capability resolution folds the last values. Adapters own choosing a valid session append boundary; the ACP transport has no runtime switch path. (The former ACP idle-switch anchoring — holding a pending idle selection until the next prompt submission — left with that bridge.)
#### In-process tools
@@ -117,10 +117,10 @@ fs/web/todo execute in-process, so their sandbox semantics are policy at their s
### Testing
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, narrator coalescing, ACP advertisement and validation, and turn-enclosed config writes.
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. The real ACP composition pins permission switching and rejects unknown presets. CI rejects a silent all-skip.
- **Unit:** pin platform selection and profiles, fail-closed runner classification, per-call mode/root resolution, per-process facts, escalation validation and outcomes, permission preset folding and write-through, and narrator coalescing.
- **Keyless real-runner:** exercise bwrap, Landlock, and Seatbelt against real filesystem effects at provider and bash-consumer layers; one real Cordis context concurrently drives two project sessions through shipped bash and fs tools, proving own-root success and sibling-root denial. Packed-install coverage proves the registry launcher remains executable. CI rejects a silent all-skip.
- **With-key:** start the real ACP composition in read-only mode, let a model-driven bash write hit the runner's denial marker, then drive the bridge answerer and disk effect through granted and rejected workspace-write retries; unavailable credentials or runners self-skip.
- **Snapshot:** pin the permission config-option wire, preset and knob events, prompt deltas and notices, and both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent, and policy scenarios switch explicitly.
- **Snapshot:** pin prompt deltas and notices plus both scripted approval branches. A real ACP example scenario places its session under the user home while the deployment fallback points at `/tmp`, then pins a successful deployment-selected workspace-write mutation; this distinguishes session-root resolution from the process fallback without depending on runner-specific denial text. Other snapshots start unconfined so unrelated fixtures remain platform-independent.
## Deferred phases
@@ -152,7 +152,7 @@ Each phase gets its full design when picked up, validated against the code at th
- **Narrate via `agent/user-message` + a bus event** — rejected: it presupposes a turn-entry seam that does not exist (the real seam is `agent/prompt-submit`), and pre-step's position serves both the coalesced turn-entry notice and the mid-turn immediacy bound with one listener.
- **A standing prompt statement of the sandbox mode (+ a switch narrator)** — shipped first, then removed on live evidence: with `Bash commands run under the "read-only" file sandbox.` in every request, the model refused to ATTEMPT denied-then-escalatable work (five of twelve turns in the first manual session ended with zero tool calls), turning the sandbox into a soft lockout. The denial marker names the mode at the moment it matters and the escalation fields carry the recovery; the approval knob keeps its statement because an auto-rejection is behaviorally indistinguishable from a human "no".
- **Track "last told" with its own bookkeeping events** — rejected: the `request/header` fold already records the exact prompt the model saw; parsing the closed candidate sentences back replaces a second bookkeeping stream — events are needed only where they ARE the store.
- **ACP session modes instead of config options** — rejected: the preset is already one deployment-defined config-option select, and modes are slated for removal in ACP v2.
- **Independent sandbox and approval selectors** — rejected: one deployment-defined permission preset keeps the two policy knobs coherent for UI clients that expose runtime switching.
## Consequences
@@ -161,11 +161,11 @@ What shipped pins — the tiers in Testing hold each:
- A denied command retried with `sandbox_permissions` + `justification` prompts the user through the composed answerer chain; a grant runs THAT call under the wider mode (result facts say so) while every other call keeps its own effective mode; every non-grant outcome produces its distinct error text and executes nothing.
- The escalation fields exist exactly when the mounted executor confines; a request that is not strictly wider than the call's effective mode fails closed with its own text and prompts no one; a deployment with no ApprovalService fails escalating calls closed and leaves plain calls untouched.
- The system prompt never states the sandbox mode (an approval `'never'` policy is the one stated knob), and the whole exchange — headers, knob events, notices, approvals, results — reconstructs from the session log alone, with no event types beyond the two knob events.
- N idle-time flips produce at most one anchored event per knob (a net-zero sequence anchors none — a no-op push from a client echoing current selections records nothing); an approval-policy switch is narrated in at most one coalesced notice; a mid-turn sandbox switch is honored by the next call's stamp.
- A resumed session's overrides apply and are reported to the editor with no special-casing; a default changed while the process was down is narrated before the session's first new request, attributed to the operator.
- Two concurrent sessions never see each other's state, notices, or config options.
- One preset selection records only changed knob values, while a no-op selection records nothing; an approval-policy switch is narrated in at most one coalesced notice, and a committed sandbox switch is honored by the next call's stamp.
- A resumed session's overrides apply with no catch-up state; a default changed while the process was down is narrated before the session's first new request, attributed to the operator.
- Two concurrent sessions never see each other's state or notices.
- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd.
- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and the ACP handler surface.
- `agent-loop` is untouched — everything rides `systemPrompt.section`, `SessionEventMap` merging, `agent.inject()`, `agent/pre-step`, `agent/prompt-submit`, and capability-owned policy resolution.
Costs and accepted limits:
@@ -178,7 +178,6 @@ Costs and accepted limits:
- **The model may over-ask.** Escalating without denial grounding, or picking `danger-full-access` where `workspace-write` suffices: the description steers and the enum forces the ladder, but the human prompt is the actual gate; the `approval/asked` reasons make over-asking auditable, and a `prepend` policy answerer can auto-reject patterns a deployment never wants.
- **The advertised target set is static while the effective mode is per-session** (schemas are registry-global) — a session already at the widest mode is still offered the fields. Harmless by construction: the strict-wider check at execution, not the enum, is the safety boundary — a non-widening request fails with its own text and never prompts anyone.
- **A granted escalation is not a working sandbox.** An unavailable backend still fails closed even for a granted escalation to a confining mode — at `confine()` when the platform has no chain or every probe fails, at execution when an unprobed sole runner refuses (classified as a sandbox failure, not a command failure) — while a granted `danger-full-access` run never touches the provider at all: there the grant, not the probe, is the authority.
- **An idle switch lives in bridge memory until the next prompt submission anchors it.** A crash in that window reverts it (reported on `session/load`), and a session that never submits another prompt never persists it — accepted, with a loop-owned idle commit turn left as future work if durability becomes required.
- **The approval narrator's restart baseline parses prompt prose.** The closed candidate sentence is owned by the writing module itself, so a wording change is a coordinated writer+parser edit in one file; a session whose headers predate the section silently adopts the current policy without a notice.
- **The approval section is still a dynamic prompt surface** (a `'never'` switch breaks provider prompt-prefix caching for that session). Accepted: policy switches are rare, and a model acting on a stale `'never'` is worse. The sandbox knob no longer touches the prompt at all.
- **The model may hold a stale belief about the sandbox mode** (nothing announces a switch). Accepted deliberately: the next attempt's marker or success corrects it, and the observed failure mode of announcing — preemptive refusal — is worse than one wasted retry.
@@ -192,7 +191,7 @@ Costs and accepted limits:
- **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
- **Which tools actually run confined?** OS subprocesses through `ctx.bash` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary).
- **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `task_output` and may ground a new exact-command retry.
- **When does an editor's mode switch take effect?** Mid-turn: appended immediately, honored by the very next call's stamp. Idle: held on the bridge's session record, anchored at the next `agent/prompt-submit` inside its open turn, with N flips coalescing to at most one event (none if net-zero); a crash before anchoring reverts it and `session/load` reports the truth. The model is not told its next command simply behaves under the new mode.
- **When does a runtime mode switch take effect?** Once its session event commits, the very next capability resolution folds and stamps the new mode. The model is not told a standing mode; its next command simply behaves under the new policy, and any denial names that policy at the point of use.
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline changes behavior the same way a switch does (the approval policy, being stated, is additionally narrated with operator/config attribution).
- **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`.
@@ -10,7 +10,7 @@ Status: implemented
harness 是一个 SDK,因此约束必须是开发者可组合的能力:是否启用沙箱、每个平台使用哪个后端,都应作为一等条目写在叶子 `cordis.yml` 中,而非藏在某个执行器的私有机制里。而首选 runner `bwrap` 恰恰在沙箱最重要的主机上不可用(精简容器、禁用了非特权 userns、LSM 拒绝 `mount`),因此备选 runner 必须随 SDK 一起交付,而不能假设主机已有。
仅有约束还留下两个缺口。拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 `workspace-write``danger-full-access`,从而使沙箱形同虚设。而模型可见的旋钮(沙箱模式批准策略在 agent 生命周期内变化——ACP 用户切换按会话设置、运维人员在进程停止期间编辑 `cordis.yml`——模型绝不能基于过时的信念行动:每次请求时的实际状态是什么、agent 存活期间发生了什么变化、无人看管时又发生了什么变化,都需要有明确答案
仅有约束还留下两个缺口。拒绝后没有升级路径就是死路:模型只能放弃,这会迫使运维人员全局配置 `workspace-write``danger-full-access`,从而使沙箱形同虚设。而沙箱模式批准策略也会通过部署配置或可选的 UI 策略控件在 agent 生命周期内变化;执行与模型可见的策略必须派生自同一份已记录的状态
## 决策
@@ -40,13 +40,13 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是
配置错误大声失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段(命令 spawn 之前)抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner seam。
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。提示词不声明沙箱模式,以避免基于常驻标签的预防性拒绝。当 `dsh-permission` 被组合时,ACP 暴露一个 `Permissions` 选择器,其 preset 同时写入两个旋钮事件;不匹配的旋钮组合显示为仅可切换离开的 `custom`。只有切换到确定性的 `'never'` 批准策略才会在提示词中声明并叙述
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions``justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。提示词不声明沙箱模式,以避免基于常驻标签的预防性拒绝。当 `dsh-permission` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮;不匹配的组合折叠为 `custom`。[ACP 自动化组合](../../../../examples/acp-agent/README.md)不挂载该 UI 服务,而是显式选定其部署模式
### 设计细节
#### 范围界定
OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还将适用于 ACP subagent 子进程。文件系统、web 和其他工具在进程内执行,需要在各自的 seam 层面实施策略;argv 包装无法约束一个闭包了 `ctx` 的函数。既有的 bash request/spec 拆分承载按调用的覆盖,而 `tools/pre-execute` 和 approval seam 负责人的决策。
OS 子进程约束适用于 bash 执行器(包括钩子命令),后续还将适用于 ACP subagent 子进程。文件系统、web 和其他工具在进程内执行,需要在各自的 seam 层面实施策略;argv 包装无法约束一个闭包了 `ctx` 的函数。既有的 bash request/spec 拆分承载按调用的覆盖,而 `tools/pre-execute` 和 approval seam 负责一次性策略决策。
#### seam`ctx.sandbox`
@@ -92,7 +92,7 @@ Landlock launcher 源码和包工作区位于 `native/landlock-run`,与 harnes
effective(session) = findLast(the session's own knob events)?.value ?? the composition-config default
```
默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离(一个编辑器标签页的 `workspace-write` 不会干扰另一个的 `read-only`都是构造性的自然结果,且不存在任何外部配置存储。
默认值是组合配置(`cordis.yml`)——运维人员拥有,进程范围。运行时切换是会话范围的覆盖,记录为该会话自身日志中的一条仅日志事件。重启免疫(恢复会话时回放其日志,覆盖自然恢复,无需追赶机制)和多会话隔离都是构造性的自然结果,且不存在任何外部配置存储。
**每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`):
@@ -107,9 +107,9 @@ interface SessionEventMap {
沙箱模式不在提示词中叙述;拒绝结果在需要时报告模式,避免基于常驻标签的预防性拒绝。批准策略不同:只有 `'never'` 被声明,因为自动拒绝在行为上与用户的「不」无法区分。策略变更通知被合并,由下一个步骤前检查点递送,重启后有基于日志的回退。通知来源从事件位置推断:最后一个 request header 之后的旋钮事件是用户驱动的;未记录的漂移是运维人员或配置驱动的。
**编辑器界面**是协议原生的[会话配置选项](https://agentclientprotocol.com/protocol/session-config-options)——该规范对 session modes 的替代(计划在 ACP v2 中移除),已有 SDK 类型。当 `ctx.permission` 被组合时,bridge 在 `session/new``session/load` 中公布一个 `permission` 选择器(category `mode`);其选项是部署的 preset 表,其 `currentValue``PermissionService.current()` 对会话日志加组合默认值的结果。随附的 `workspace-write``danger-full-access` preset 各自捆绑一个沙箱模式与一个批准策略,并写入两个领域 setter;preset 表之外的旋钮组合报告为仅可切换离开的 `custom``session/set_config_option` 通过权限服务验证并切换,然后返回完整的刷新状态(规范契约)
**可选的 UI 界面**是 `PermissionService`:一张部署定义的 preset 表,每个条目捆绑一个沙箱模式与一个批准策略。随附的 `workspace-write``danger-full-access` preset 写入两个领域 setter;preset 表之外的旋钮组合报告为 `custom`UI 适配器可以把该表暴露为选择器。仅面向自动化的 ACP 传输层不公布任何配置选择器,也不挂载权限 preset 服务
**轮次封闭是提交边界。** 开放轮次中的切换立即追加。空闲切换保持在 bridge 记录上待定,在下一次提示词提交时、assembly 或执行之前追加到开放轮次中;每个旋钮以最后写入为准。开放性来自日志边界而非 `agent.status`setter 不从 `session/event` 监听器内追加,因为那会重排后续观察者。锚定之前,响应叠加待定值。崩溃丢弃它,重新加载返回持久 fold。
**已提交的事件是提交边界。** 运行时切换在目标会话上记录其 preset 和发生变化的旋钮事件,之后每次能力解析都折叠最后的值。选择有效的会话追加边界由适配器负责;ACP 传输层没有运行时切换路径。(原先 ACP 的空闲切换锚定——将待定的空闲选择保留到下一次提示词提交——已随该桥一并移除。)
#### 进程内工具
@@ -117,10 +117,10 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
### 测试
- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、叙述器合并、ACP 公布和验证、轮次封闭的配置写入
- **Keyless 真实 runner** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。真实 ACP 组合固定权限切换并拒绝未知 preset。CI 拒绝静默全跳过。
- **单元测试:** 固定平台选择和 profile、失败关闭的 runner 分类、按调用的模式/根目录解析、按进程事实、升级验证和结果、权限 preset fold 和写入透传、以及叙述器合并。
- **Keyless 真实 runner** 在提供方和 bash 消费方层面对 bwrap、Landlock 和 Seatbelt 执行真实文件系统效果测试;一个真实 Cordis 上下文通过已交付的 bash 和 fs 工具并发驱动两个项目会话,证明在自身根目录写入成功、在兄弟根目录写入被拒绝。Packed-install 覆盖率证明注册表 launcher 保持可执行。CI 拒绝静默全跳过。
- **With-key** 以只读模式启动真实 ACP 组合,让模型驱动的 bash 写入命中 runner 的拒绝标记,再通过已授权与被拒绝的 workspace-write 重试驱动 bridge 应答器和磁盘效果;不可用的凭证或 runner 自动跳过。
- **快照:** 固定权限 config-option 协议格式(wire format)、preset 和旋钮事件、提示词 delta 和通知以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关;策略场景显式切换
- **快照:** 固定提示词 delta 和通知以及两个脚本化的 approval 分支。一个真实 ACP 示例场景把会话放在用户主目录下,同时让部署后备根目录指向 `/tmp`,然后固定一次成功的、由部署选定的 workspace-write 变更;这能区分会话根目录解析与进程后备值,而不依赖 runner 特定的拒绝文本。其他快照以无约束启动,使无关 fixture(测试前置数据)保持平台无关。
## 延迟阶段
@@ -152,7 +152,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **通过 `agent/user-message` + 总线事件叙述**:否决。它预设了一个不存在的轮次入口 seam(真正的 seam 是 `agent/prompt-submit`),而步骤前检查点的位置使一个监听器能够同时服务合并的轮次入口通知和轮中即时性约束。
- **提示词中常驻声明沙箱模式(+ 切换叙述器)**:先交付后移除,基于实际证据:当每个请求中都有 `Bash commands run under the "read-only" file sandbox.` 时,模型拒绝尝试被拒绝后可升级的工作(首次手动会话中十二个轮次有五个以零工具调用结束),将沙箱变成了软锁定。拒绝标记在需要时命名模式,升级字段承载恢复路径;批准旋钮保留其声明,因为自动拒绝在行为上与人的「不」无法区分。
- **用专门的簿记事件追踪「上次告知」**:否决。`request/header` fold 已记录模型看到的确切提示词;将封闭的候选句子解析回来替代了第二条簿记流——事件仅在它们本身即为存储时才需要。
- **ACP session modes 而非 config options**:否决。preset 已经是一个部署定义的 config-option 选择器,且 modes 计划在 ACP v2 中移除
- **相互独立的沙箱与批准选择器**:否决。一个部署定义的权限 preset 让两个策略旋钮对暴露运行时切换的 UI 客户端保持一致
## 后果
@@ -161,11 +161,11 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- 被拒绝的命令以 `sandbox_permissions` + `justification` 重试时,通过组合的应答器链提示用户;授权使该次调用在更宽模式下运行(结果事实如此报告),而其他所有调用保持各自的有效模式;每种非授权结果产生各自不同的错误文本且不执行任何内容。
- 升级字段恰好在已挂载的执行器约束时存在;不严格宽于调用有效模式的请求以自身文本失败关闭且不提示任何人;没有 ApprovalService 的部署对升级调用失败关闭,对普通调用不影响。
- 系统提示词从不声明沙箱模式(批准 `'never'` 策略是唯一被声明的旋钮),且整个交互——header、旋钮事件、通知、批准、结果——仅从会话日志即可重建,除两个旋钮事件外无额外事件类型。
- N 次空闲切换每个旋钮最多产生一个锚定事件(净零序列不锚定任何事件——客户端回显当前选择的无操作推送不记录任何内容;批准策略切换最多以一条合并通知叙述;轮中沙箱切换由下一次调用的盖章兑现。
- 恢复的会话的覆盖生效并报告给编辑器,无需特殊处理;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。
- 两个并发会话永远看不到彼此的状态通知或配置选项
- 一次 preset 选择只记录发生变化的旋钮值,而无操作的选择不记录任何内容;批准策略切换最多以一条合并通知叙述,已提交的沙箱切换由下一次调用的盖章兑现。
- 恢复的会话的覆盖直接生效,无需追赶状态;进程停止期间变更的默认值在会话的首个新请求前被叙述,归因于运维人员。
- 两个并发会话永远看不到彼此的状态通知。
- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。
- `agent-loop` 未被触及——一切搭载 `systemPrompt.section``SessionEventMap` 合并、`agent.inject()``agent/pre-step``agent/prompt-submit` ACP handler 表面
- `agent-loop` 未被触及——一切搭载 `systemPrompt.section``SessionEventMap` 合并、`agent.inject()``agent/pre-step``agent/prompt-submit`由能力拥有的策略解析
代价与已接受的限制:
@@ -178,7 +178,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **模型可能过度请求。** 在没有拒绝依据的情况下升级,或在 `workspace-write` 足够时选择 `danger-full-access`:描述引导且枚举强制阶梯,但人的提示词是实际门控;`approval/asked` 原因使过度请求可审计,且 `prepend` 策略应答器可以自动拒绝部署永远不想要的模式。
- **公布的目标集是静态的,而有效模式是按会话的**(schema 是注册表全局的)——已处于最宽模式的会话仍被提供这些字段。构造上无害:执行时的严格放宽检查(而非枚举)是安全边界——非放宽请求以自身文本失败且不提示任何人。
- **授权的升级不等于可工作的沙箱。** 不可用的后端即使对授权升级到约束模式也仍然失败关闭——在平台没有链或所有探测失败时于 `confine()` 阶段,在未探测的唯一 runner 拒绝时于执行阶段(归类为沙箱失败而非命令失败)——而授权的 `danger-full-access` 运行根本不触及提供方:此时授权(而非探测)是权威。
- **空闲切换存在于 bridge 内存中,直到下一次提示词提交锚定它。** 该窗口内的崩溃将其回退(在 `session/load` 时报告),且永不再提交提示词的会话永不持久化它——已接受,loop 拥有的空闲提交轮次留作未来工作(如果持久性成为需求)。
- **批准叙述器的重启基线解析提示词文本。** 封闭的候选句子由写入模块本身拥有,因此措辞变更是同一文件中写入器+解析器的协调编辑;header 早于该段落的会话静默采用当前策略而不发通知。
- **批准段落仍是动态提示词表面**`'never'` 切换会破坏该会话的提供方提示词前缀缓存)。已接受:策略切换罕见,且模型基于过时的 `'never'` 行动更糟。沙箱旋钮不再触及提示词。
- **模型可能持有关于沙箱模式的过时信念**(没有任何东西宣布切换)。有意接受:下一次尝试的标记或成功会纠正它,而宣布的观察到的失败模式——预防性拒绝——比一次浪费的重试更糟。
@@ -192,7 +191,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自 seam 层
- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 仅声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **哪些工具实际在约束下运行?** 通过 `ctx.bash` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。
- **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `task_output` 呈现,并且可以作为一次新的精确命令重试的依据。
- **编辑器的模式切换何时生效?** 轮中:立即追加,由下一次调用的盖章兑现。空闲:保持在 bridge 的会话记录上,在下一次 `agent/prompt-submit` 时锚定到其开放轮次中,N 次切换合并为最多一个事件(净零则无);锚定前崩溃回退它,`session/load` 报告真实状态。模型不被告知——其下一个命令直接在新模式下运行。
- **运行时模式切换何时生效?** 一旦其会话事件提交,紧接着的下一次能力解析就会折叠并盖章新模式。模型不被告知常驻模式;其下一个命令直接在新策略下运行,任何拒绝都会在使用点命名该策略
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值以与切换相同的方式改变行为(批准策略因被声明,还额外以运维人员/配置归因叙述)。
- **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`
@@ -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
2026-07-07-mcp-client-plugin.md: 90c43383882034954f33b79459fac273e60b9e0e
2026-07-07-mcp-client-plugin.zh.md: a60aa537a03dec5add7e91fd78b91fece4e4a259
2026-07-07-mcp-client-plugin.md: 2a8ad62e3bad1f2ae47100294f5dba25ceb47d12
2026-07-07-mcp-client-plugin.zh.md: 9860d5ec805b394eb0c5f59f54349264593a5599
@@ -99,7 +99,7 @@ This server-qualified shape is the de-facto standard among multi-server agent cl
1. On connect: drain `client.listTools()` pagination, derive every tool's `publicName`, then register each as a raw `ToolDefinition` via `ctx.tools.register()`. The MCP JSON Schema and description pass through unchanged (no `defineTool` DSL conversion); only the model-facing `name` is replaced.
2. Listen for `notifications/tools/list_changed` → re-run the same sync (dispose previous generation, register new). Deterministic names mean unchanged tools keep their names across re-syncs.
3. The executor closes over `rawName`; the public name is never sent to the server and never parsed to recover the raw name.
4. No `presentCall`/`presentResult`the ACP bridge's generic-card fallback handles rendering.
4. No `presentCall`/`presentResult`UI consumers use the provider-neutral generic-card fallback.
5. Tools are transparent in the system prompt — no "[via MCP]" annotation beyond the name itself.
### Public name normalization
@@ -201,7 +201,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex
- **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, result mapping, cancellation, config schema validation. 100% per-file coverage gates the package.
- **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, duplicate-`serverName` rejection, disposal.
- **Snapshot**: deliberately none. MCP tools introduce no new transcript surface — they register as raw `ToolDefinition`s and render through the ACP bridge's generic-card fallback, which the bridge's unit suite already pins (`packages/ui/acp/tests/stream-update.spec.ts`). Adding an MCP server to the snapshot example's `cordis.yml` would mutate the pinned `text-turn` system-prompt fixture (forcing a with-key re-record of every recorded expected output) and make every replay depend on spawning an external MCP server process for zero new rendering behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
- **Snapshot**: deliberately none. MCP tools introduce no new presentation shape — they register as raw `ToolDefinition`s and UI consumers use the generic-card fallback already pinned by their presentation suites. Adding an MCP server to a runnable snapshot composition would mutate its pinned system-prompt fixture and make every replay depend on spawning an external MCP server process for no new behavior. If a later change gives MCP tools their own render intent, that change names its snapshot coverage then.
## Consequences
@@ -99,7 +99,7 @@ type Config = StdioConfig | StreamableHttpConfig
1. 连接时:遍历 `client.listTools()` 的分页结果,推导每个工具的 `publicName`,然后通过 `ctx.tools.register()` 将其注册为原始 `ToolDefinition`。MCP 的 JSON Schema 和描述原样透传(不做 `defineTool` DSL 转换);仅替换模型可见的 `name`
2. 监听 `notifications/tools/list_changed` → 重新执行同步(dispose 上一代、注册新一代)。确定性命名意味着未变化的工具在重新同步后保持原名。
3. 执行器闭包持有 `rawName`;公开名称永远不发送给服务器,也永远不被解析以还原原始名称。
4. 无 `presentCall`/`presentResult`——ACP 桥接的通用卡片兜底负责渲染
4. 无 `presentCall`/`presentResult`——UI 消费方使用提供方无关的通用卡片兜底。
5. 工具在系统提示词中是透明的——除名称本身外不附加「[via MCP]」标注。
### 公开名称规范化
@@ -201,7 +201,7 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
- **单元测试**`tests/mcp-client.spec.ts``tests/apply.spec.ts`mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、代切换/回滚、重新同步失败时的保留、结果映射、取消、配置 schema 校验。100% 逐文件覆盖率门禁约束该包。
- **E2E**`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything``@modelcontextprotocol/server-filesystem`stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、重复 `serverName` 拒绝、dispose。
- **快照**:刻意不做。MCP 工具不引入新的 transcript(文本记录)呈现面——它们以原始 `ToolDefinition` 注册,通过 ACP 桥接的通用卡片兜底渲染,该兜底已由桥接的单元测试套件固定(`packages/ui/acp/tests/stream-update.spec.ts`)。将 MCP 服务器添加到快照示例的 `cordis.yml` 会改变已固定的 `text-turn` 系统提示词 fixture(迫使每条录制的预期输出都需要带密钥重新录制),且使每次回放依赖于 spawn 外部 MCP 服务器进程——而新增渲染行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。
- **快照**:刻意不做。MCP 工具不引入新的展示形态——它们以原始 `ToolDefinition` 注册,UI 消费方使用各自展示测试套件已固定的通用卡片兜底。将 MCP 服务器添加到某个可运行的快照组合会改变已固定的系统提示词 fixture,且使每次回放依赖于 spawn 外部 MCP 服务器进程而新增行为为零。如果后续变更为 MCP 工具引入专属渲染意图,该变更届时自行声明快照覆盖。
## 后果
@@ -4,6 +4,8 @@ Status: implemented
> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed.
> **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces.
## Problem
Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log.
@@ -118,7 +120,7 @@ No new cordis event is declared (`mode/set` rides `session/event`; the listeners
Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
The canonical [`examples/acp-agent`](../../../../examples/acp-agent/) composition mounts the mode and question-tool plugins on the full ACP coding server; plan mode is an additive session feature, not a second server profile. Its snapshot suite pins the plan-shaped initial header, a real read, scripted approval, stable tool schemas across the pure-removal header delta, a subsequent edit, rejection feedback, and the keyless mode wire. A self-skipping real-API smoke boots that same leaf, verifies the file before approving the review, and verifies the approved implementation afterward.
The ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas.
## FAQ
@@ -138,13 +140,13 @@ Behavioral clarifications of the chosen design; rejected designs live in [Altern
**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`.
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs and belong to ACP's `session/set_config_option`; the division this proposal pins is picker-to-modes / knobs-to-config-options, recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md) now that both this stack's picker and the sandbox stack's config options are landed. A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the [automation-only protocol decision](../simplification/2026-07-23-acp-automation-only-protocol.md). A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
## Prior art
A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on.
The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. This is the surface [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records as the gap, and what sizes the vocabulary as named modes rather than a flag.
The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control.
The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract.
@@ -186,7 +188,7 @@ What holds now, pinned by the unit, protocol, snapshot, and real-API tiers:
- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes.
- Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning.
- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`.
- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; ACP mode updates and each surface's user-interaction provider carry the human side.
- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; each human-facing surface's user-interaction provider carries the review.
- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row.
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). The ACP mode surface carries the picker while sandbox, approval, and model selectors remain config options under the division pinned in the [FAQ](#faq) and [feature matrix](../../../../packages/ui/acp/acp-feature-support.md). If ACP removes session modes in favor of config options, the picker mapping can migrate without changing the logged mode state or model surface.
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither.

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