Merge origin/master into task/command-feedback-master

This commit is contained in:
Turtle
2026-07-29 21:38:55 +08:00
672 changed files with 21330 additions and 3926 deletions

View File

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

View File

@@ -2,21 +2,21 @@
[English](README.md) | 中文
面向用户的交互通道和进程外 SDK 服务器。这些是**产品** package由用户或 SDK 客户端直接操作的真实接口。
面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包package由用户或 SDK 客户端直接操作的真实接口。
| 包 | 职责 | ctx 键 |
|---|---|---|
| `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` |
| `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` |
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`用一个产品级选择组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` |
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` |
| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` |
| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools` |
| `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents` |
| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents` |
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的用户发现与分派通道;命令输入和输出不会成为模型消息。
UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户发现与分派通道;命令输入和输出不会成为模型消息。
`user-approval``user-interaction``tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent智能体的通道或自动化传输层所有`user-interaction` 保持提供方无关(`ctx.userInteraction``tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现
`user-approval``user-interaction``tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent智能体的通道或自动化传输层提供`user-interaction` 保持提供方无关(`ctx.userInteraction``tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)`tui-demo``acp-demo``jsonrpc-demo`)。`acp-demo``jsonrpc-demo` 持有启动 bin`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI 启动。`ui/` 保留可复用的用户SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)`tui-demo``acp-demo``jsonrpc-demo`)。`acp-demo``jsonrpc-demo` 各自提供启动 bin`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACPAgent Client Protocol传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
README.md: 77397aadf8dd070d962d1a4f95dea2e4700a6c15
README.zh.md: 8f02325271548b652b069433bcdb9c1c99de547e
README.zh.md: 22f6058cbd895f6be48f0482d1b9c2e200c55d21

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
由插件拥有、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
## 服务契约
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true若载荷由命令的权威领域事件持有该命令会将 `recordInput` 设为 false`command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true若载荷由命令的权威领域事件持有该命令会将 `recordInput` 设为 false`command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer注册或移除命令时系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造`commandId`、解析器得到的结构化名称、发起方 `CommandSource`,以及 `args``recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成`commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args``recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_``-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。
处理器返回 `success``error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方拥有由此产生的消息契约。注册表会处理器完成所提供的中止信号竞速,但不协作的处理器可能在调用方停止等待后继续产生自身的外部副作用。
处理器返回 `success``error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息契约。注册表会同时等待处理器完成所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。
## 组合
@@ -32,9 +32,9 @@
#### KV Cache 影响
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。被命令变更的领域拥有之后产生的所有缓存影响。
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。发生变更的领域负责之后产生的所有缓存影响。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
README.zh.md: 63615654769bf4ed7a69c09dc818af034c3a3c3c
README.zh.md: 1c27f5edf2f1f172aa6303697b17e2e77a65842a

View File

@@ -2,11 +2,11 @@
[English](README.md) | 中文
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC使进程外 SDK 客户端能够驱动 harness agent智能体。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;传输与具名线类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC使进程外 SDK 客户端能够驱动 harness agent智能体。[`HarnessSdkServer`](src/server.ts) 负责协议方法和通知;传输与具名协议类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
## 组装
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务建立快照时的生命周期 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;未被持有`deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他未被持有的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务对生命周期建立快照时记录的 `local` 标志为 true服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;尚无适配器负责`deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他尚无适配器负责的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
## 配置
@@ -40,7 +40,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **协议没有逐会话关闭或提示词取消方法**SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。
- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa
README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e
# pnpm run verify-translation-pairing --write packages/ui/permission/README.md
README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4
README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa

View File

@@ -8,6 +8,8 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
## Model Experience
Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
@@ -18,7 +20,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch.
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.

View File

@@ -8,6 +8,8 @@
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key单元折叠三个全量值旋钮事件在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
## 模型体验
间接地,通过 `dsh-user-approval``dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
@@ -18,7 +20,6 @@
## 已知限制与延期工作
- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前ACP 桥接层是唯一的选择器preset 表为下一个公开运行时策略切换的交互式入口保留。
- **只组合两个机制调节项**preset 选择沙箱模式和审批策略agent智能体profile 选择尚未纳入 `PresetSpec`
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。

View File

@@ -15,12 +15,21 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -28,22 +37,27 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
"schemastery": "^3.18.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -0,0 +1,10 @@
/**
* Client-namespace projection of the permission domain: a pure re-export of
* the package's types outlet. Client code imports ONLY the client namespace
* (repo discipline), so `./client` projects the same single-source content
* `./types` serves to host consumers — zero duplication.
*
* @module @deepseek-ai/dsh-permission/client
*/
export type * from './types.ts'

View File

@@ -3,13 +3,16 @@
* approval-policy knobs. A switch records the selected preset, then writes
* changed knobs through their canonical setters. Execution, prompt narration,
* and replay keep reading their knob folds. The preset event preserves user
* intent when two presets share a bundle.
* intent when two presets share a bundle. The read side ships as the
* `permissions` session projection; the write side ships as the
* `/permission` command — both optional children over the same service.
*
* @module dsh-permission
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { z as zod } from 'zod'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
@@ -18,6 +21,16 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a
import type {} from '@deepseek-ai/dsh-bash'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
import type { PermissionSelect, PresetOption } from './types.ts'
// The `permissions` projection-key declaration lives in src/types.ts (its one
// home); this re-export projects the type face onto the package root AND
// keeps the module edge in the emitted index.d.ts, so aggregate programs
// consuming the declarations still receive the SessionProjectionMap merge.
export type * from './types.ts'
declare module 'cordis' {
interface Context {
@@ -49,16 +62,6 @@ export interface PresetSpec {
description?: string
}
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** Stable option value: the table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means. */
description?: string
}
/**
* Returned when effective knob values match no table entry. Clients may show
* it as the current value, but it is never a switch target or event payload.
@@ -79,6 +82,50 @@ export function effectivePermissionPreset(events: readonly SessionEvent[]): stri
return undefined
}
/**
* The projection unit's state: the last seen value of each knob event, null
* before an override (composition defaults apply at view time). Plain JSON
* (persisted-cache precondition).
*/
export interface KnobState {
/** Last `permission/preset` payload, or null. */
preset: string | null
/** Last `sandbox/mode` payload, or null. */
sandbox: SandboxMode | null
/** Last `approval/policy` payload, or null. */
approval: ApprovalPolicy | null
}
/** State for the empty log: every knob at its composition default. */
const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
/**
* One-event knob transition (the projection unit's `apply`). Uninterested
* events return the same reference — the registry's change gate.
* @param state - the folded knob state before `event`.
* @param event - one committed session event.
* @returns the next state; the same reference when the event is not a knob.
*/
export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState {
switch (event.type) {
case 'permission/preset':
return { ...state, preset: event.data.preset }
case 'sandbox/mode':
return { ...state, sandbox: event.data.mode }
case 'approval/policy':
return { ...state, approval: event.data.policy }
default:
return state
}
}
/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */
function foldKnobs(events: readonly SessionEvent[]): KnobState {
let state = EMPTY_KNOBS
for (const event of events) state = applyKnobEvent(state, event)
return state
}
/** The {@link PermissionService} config: the deployment's preset table. */
export interface Config {
/**
@@ -128,6 +175,55 @@ export class PermissionService extends Service {
if (ctx.bash.sandboxMode === undefined) {
throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration')
}
// The permissions projection unit: fold the three whole-value knob
// events; view derives the select over the composition defaults this
// service already owns. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
// zod `.optional()` types the key `string | undefined` while the domain
// says `description?: string`; on the JSON wire the two serialize
// identically (absent), so the cast records exactly that
// exactOptionalPropertyTypes widening (the Wire<T> precedent).
const selectSchema = zod.object({
options: zod.array(zod.object({
value: zod.string().min(1),
name: zod.string().min(1),
description: zod.string().optional(),
})),
currentValue: zod.string().min(1),
}) as unknown as zod.ZodType<PermissionSelect>
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'permissions', KnobState>({
key: 'permissions',
schema: selectSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
view: state => this.selectFor(state),
stateVersion: 1,
})
})
// The /permission command: the one write path a web client uses (the
// popup contribution submits the picked preset as this line). The child
// activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'permission',
description: 'Switch the permission preset (sandbox mode + approval policy)',
input: { hint: '<preset>' },
handler: ({ agent, rawInput }) => {
const name = rawInput.trim()
if (name === '') {
return { kind: 'success', text: `Current permission preset: ${this.current(agent.session.events)}. Available: ${this.names.join(', ')}.` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown permission preset "${name}" (available: ${this.names.join(', ')})` }
}
this.set(agent.session, name)
return { kind: 'success', text: `Permission preset: ${name}.` }
},
})
})
}
/**
@@ -146,13 +242,17 @@ export class PermissionService extends Service {
* @returns the effective preset name, or `custom` when nothing matches.
*/
current(events: readonly SessionEvent[]): string {
const sandbox = effectiveSandboxMode(events) ?? this.ctx.bash.sandboxMode
const approval = effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask'
return this.derive(foldKnobs(events))
}
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
private derive(state: KnobState): string {
const sandbox = state.sandbox ?? this.ctx.bash.sandboxMode
const approval = state.approval ?? this.ctx.approval.config.policy ?? 'ask'
const matches = (spec: PresetSpec): boolean => spec.sandbox === sandbox && spec.approval === approval
const folded = effectivePermissionPreset(events)
if (folded !== undefined) {
const spec = this.presets[folded]
if (spec !== undefined && matches(spec)) return folded
if (state.preset !== null) {
const spec = this.presets[state.preset]
if (spec !== undefined && matches(spec)) return state.preset
}
for (const [name, spec] of Object.entries(this.presets)) {
if (matches(spec)) return name
@@ -160,6 +260,23 @@ export class PermissionService extends Service {
return CUSTOM_PRESET
}
/**
* Build the whole select value for one folded knob state: every table
* option in declaration order, `custom` appended exactly while derived.
* @param state - the folded knob overrides.
* @returns the `permissions` projection payload.
*/
selectFor(state: KnobState): PermissionSelect {
const currentValue = this.derive(state)
return {
options: [
...this.names.map(name => this.optionOf(name)),
...currentValue === CUSTOM_PRESET ? [this.optionOf(CUSTOM_PRESET)] : [],
],
currentValue,
}
}
/**
* Resolve a preset's knob bundle.
* @param name - the preset name to resolve.

View File

@@ -0,0 +1,44 @@
/**
* Pure types of the permission domain: the ONE home of the `permissions`
* projection-key declaration plus its payload types, free of this package's
* host-side value imports (cordis, schemastery). Two namespace projections
* serve it — the package root re-export for host consumers, `./client` (the
* browser half-entry's re-export) for client aggregates — with zero content
* duplication.
*
* @module @deepseek-ai/dsh-permission/types
*/
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** Stable option value: the table key, or `custom`. */
value: string
/** The display label. */
name: string
/** One user-facing sentence on what the value means; omitted when not configured. */
description?: string
}
/**
* Whole `permissions` projection value: every switchable preset in table
* order (plus the derived current-only `custom` when the knobs match no
* entry) and the effective current value.
*/
export interface PermissionSelect {
/** Switchable presets, plus `custom` appended exactly while it is current. */
options: PresetOption[]
/** The effective current value: a preset table key, or `custom`. */
currentValue: string
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* The session's permission select, folded from the three whole-value
* knob events (`permission/preset`, `sandbox/mode`, `approval/policy`)
* over the composition defaults. Key absence means no permission service
* is composed — clients hide the control.
*/
permissions: PermissionSelect
}
}

View File

@@ -34,6 +34,9 @@ describe('effectivePermissionPreset', () => {
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('permission/preset', { preset: 'workspace-write' })
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
// The backward scan steps over non-preset events to the latest selection.
session.append('sandbox/mode', { mode: 'read-only' })
expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
})
})

View File

@@ -0,0 +1,116 @@
/**
* The `permissions` projection unit and the `/permission` command: mounting
* the permission service beside the projection registry serves the whole
* select (table options + effective current value, `custom` appended exactly
* while derived) folded from the three knob events over the composition
* defaults; the command child registers `/permission` whose handler switches
* through `permission.set` (bare invocation reports, unknown names error);
* compositions without either registry are unaffected; unmounting the
* service removes the key (HMR safety).
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import CommandService from '@deepseek-ai/dsh-commands'
import PermissionService from '@deepseek-ai/dsh-permission'
import type { Config } from '@deepseek-ai/dsh-permission'
async function harness(options: { withPermission?: boolean; config?: Config } = {}): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(CommandService)
ctx.provide('bash', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
if (options.withPermission !== false) await ctx.plugin(PermissionService, options.config ?? {})
return { ctx, session: ctx.sessions.create(SessionId('perm-projected')) }
}
/** Mint a scoped agent over a live session (the command executor's addressing shape). */
async function agentFor(ctx: Context, session: Session): Promise<Agent> {
const agent = { id: session.id, session } as Agent
await ctx.plugin(Object.assign((inner: Context) => { createScope(inner, agent) }, { inject: ['commands'] }))
return agent
}
describe('permissions projection unit', () => {
it('serves the composition-default select at zero events', async () => {
const { ctx, session } = await harness()
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value).toMatchObject({ currentValue: 'workspace-write' })
expect(value?.options.map(option => option.value)).toEqual(['workspace-write', 'danger-full-access'])
})
it('folds the knob events and notifies the change feed per knob append', async () => {
const { ctx, session } = await harness()
const changes: { key: string; value: unknown; seq: number }[] = []
ctx.sessionProjections.onChanged((_session, key, value, seq) => {
changes.push({ key, value, seq })
})
ctx.permission.set(session, 'danger-full-access')
// set() appends preset + sandbox/mode + approval/policy: three knob transitions.
expect(changes).toHaveLength(3)
expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
// Unrelated event: same-reference apply, no notification.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(changes).toHaveLength(3)
})
it('appends custom as a current-only option when the knobs match no preset', async () => {
const { ctx, session } = await harness()
session.append('sandbox/mode', { mode: 'read-only' })
const value = ctx.sessionProjections.snapshot(session).values.permissions
expect(value?.currentValue).toBe('custom')
expect(value?.options.at(-1)).toMatchObject({ value: 'custom', name: 'Custom' })
})
it('has no permissions key without the service, and drops it on unload (HMR safety)', async () => {
const { ctx, session } = await harness({ withPermission: false })
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = await ctx.plugin(PermissionService, {})
expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' })
await fiber.dispose()
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
})
describe('/permission command', () => {
it('switches through permission.set and logs the lifecycle pair', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Permission preset: danger-full-access.' })
expect(ctx.permission.current(session.events)).toBe('danger-full-access')
const run = session.events.find(event => event.type === 'command/run')
expect(run?.data).toMatchObject({ name: 'permission', args: ' danger-full-access' })
})
it('reports the current preset and the table on bare invocation', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission', new AbortController().signal)
expect(execution?.result).toEqual({
kind: 'success',
text: 'Current permission preset: workspace-write. Available: workspace-write, danger-full-access.',
})
expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0)
})
it('rejects an unknown preset without touching the log', async () => {
const { ctx, session } = await harness()
const agent = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal)
expect(execution?.result).toMatchObject({ kind: 'error' })
expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0)
})
})

View File

@@ -34,6 +34,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../commands"
}
]
}

View File

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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择或缺失信息才能继续时,它可以借此向用户提出简明问题。
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择结果或缺失信息才能继续时,它可以借此向用户提出简明问题。
## 工具
@@ -15,7 +15,7 @@
- `options`:可选选项,包含 `label``description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`
- `multi_select`:该问题是否可以返回多个选中的选项。
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }``selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`
## 职责
@@ -31,17 +31,17 @@
#### Token 影响
工具可见每个请求都会产生固定的 schema 开销。
工具可见时,每个请求都会产生固定的 schema token 开销。
#### KV Cache 影响
只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能从此 schema 开始使复用失效。
只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能会使从此 schema 起的缓存复用失效。
### 工具调用历史与结果
#### 模型看到的内容
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
#### Token 影响
@@ -51,7 +51,7 @@
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON而非更丰富的内容块词汇。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/ui/user-approval/README.md
README.md: 38bcfbfe81c3ff5f16d1835259bd4c35a06dcb64
README.zh.md: 2a3a6d08d66c70a22b3a23a2341efc0452b8a782
README.zh.md: 7f2678d8572b191ec88a326374420dde7deed3dc

View File

@@ -4,9 +4,9 @@
与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once``rejected``cancelled``unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见生成的 [Cordis 目录](../../../docs/cordis-catalog/events.md)。
每个请求都必须属于一个打开的 agent智能体轮次。服务会追加一对 `approval/asked``approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会 resolve `cancelled`;如果审计追加在提交前失败,请求会被拒绝,而不会返回一项未记录的决定。
每个请求都必须属于一个尚未结束的 agent智能体轮次。服务会追加一对 `approval/asked``approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析`cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答所拥有 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其拥有的会话提供一次性机器决定。
应答者是 `approval/request` waterfall瀑布式事件监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个终应答者因为同级监听器的顺序不是策略优先级机制。ACPAgent Client Protocol自动化桥接层为其负责的会话提供一次性机器决定。
`ApprovalPolicy``'ask'``'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求,也是提示词中唯一声明的策略。切换最多产生一条合并通知:如果覆盖发生在最后一个 `request/header` 之后,则归因于用户;否则归因于操作方/配置。
@@ -49,15 +49,15 @@ Approval prompts are disabled in this session: actions that require approval are
#### Token 影响
不会产生重复的审计 token。拒绝可能以一条少量且保留的错误替换正常工具结果,而允许会保留消费方的普通结果。
不会产生重复的审计 token。拒绝可能以一条简短且会保留的错误信息替换正常工具结果,而允许会保留消费方的普通结果。
#### KV Cache 影响
仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
## 已知限制与延期工作
## 已知限制与暂缓事项
- **请求只在打开的轮次内有效**:空闲时或轮次之间调用会在审计前抛出异常;持久化的轮次外审批工作流仍属延期事项。
- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`记忆规则、撤销或授权存储;会话策略只有 `ask``never`
- **请求只在尚未结束的轮次内有效**空闲时或轮次之间发起调用会在审计前抛出异常;持久化的轮次外审批工作流仍属暂缓事项。
- **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`已记住的规则、撤销或授权存储;会话策略只有 `ask``never`
- **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 idACP 机器通道要求调用 id并会委托不含 id 的请求。
- **没有内置应答者**:无头或组合不完整的部署会 resolve 为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。

View File

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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
抽象用户交互 seam。它拥有 `ctx.userInteraction`:当面向模型的工具或权限插件需要暂停工作并询问人类决定时使用的服务
抽象用户交互 seam。它定义 `ctx.userInteraction`,供面向模型的工具或权限插件需要暂停工作并询问人类决定时使用。
## 服务:`UserInteractionService`ctx 键:`userInteraction`
@@ -19,7 +19,7 @@
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED` 等代码。
当回答包含 `custom` 时,`selected` 为空;自定义文本会覆盖所选选项,而不是补充它们。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
当回答包含 `custom` 时,`selected` 为空;自定义文本所选选项的替代而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
## 职责
@@ -31,9 +31,9 @@
#### KV Cache 影响
不会直接使缓存失效;具名消费方拥有所有请求前缀变更
不会直接使 KV Cache 失效;请求前缀的任何变更均由上述消费方负责
## 已知限制与延期工作
## 已知限制与暂缓事项
- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。
- **词汇仅包含问题表单形态**:可选项加可选自定义文本更丰富的交互形态文件选择器、diff 预览确认)尚无 seam 词汇。
- **词汇仅包含问题表单形态**:可供选择的选项加可选自定义文本更丰富的交互形态文件选择器、diff 预览确认)尚无 seam 词汇。