dsh-why-reject-me 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +256 -2
- package/client.js +475 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +7 -0
- package/index.js +125 -0
- package/locale/en.json +5 -0
- package/locale/zh.json +5 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorgen233
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,257 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-why-reject-me
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
简体中文 | [English](README_en.md)
|
|
4
|
+
|
|
5
|
+
给 DSH 的权限审批弹窗加一个可选的「拒绝原因」。拒绝时,原因会附在被拒绝工具的 **tool result** 里交给模型;留空则完全保持原生行为。
|
|
6
|
+
|
|
7
|
+
> 本项目的代码、测试与文档完全由 DeepSeek 搭建和维护。
|
|
8
|
+
|
|
9
|
+
状态:`v0.3.2`,已在 DSH Desktop `0.2.1-rc.5` 上完成真实审批验证,并验证了让 Agent 从 GitHub 直接安装。
|
|
10
|
+
|
|
11
|
+
## 目录
|
|
12
|
+
|
|
13
|
+
- [它解决什么问题](#它解决什么问题)
|
|
14
|
+
- [效果](#效果)
|
|
15
|
+
- [安装](#安装)
|
|
16
|
+
- [使用](#使用)
|
|
17
|
+
- [快捷键](#快捷键)
|
|
18
|
+
- [工作原理](#工作原理)
|
|
19
|
+
- [兼容性](#兼容性)
|
|
20
|
+
- [已知限制](#已知限制)
|
|
21
|
+
- [卸载与回滚](#卸载与回滚)
|
|
22
|
+
- [故障排查](#故障排查)
|
|
23
|
+
- [开发与测试](#开发与测试)
|
|
24
|
+
- [许可证](#许可证)
|
|
25
|
+
- [关于本项目](#关于本项目)
|
|
26
|
+
|
|
27
|
+
## 它解决什么问题
|
|
28
|
+
|
|
29
|
+
DSH 原生审批只有「允许一次 / 拒绝」两个动作。模型只知道请求被拒绝了,不知道为什么:是路径不对、命令太危险,还是这次只是在做测试。
|
|
30
|
+
|
|
31
|
+
常见的场景:
|
|
32
|
+
|
|
33
|
+
- 不想给某个操作权限,但希望模型换一种做法;
|
|
34
|
+
- 在测试权限流程,需要明确告诉模型「这次拒绝是故意的」;
|
|
35
|
+
- 先让模型解释计划、给出 diff,再决定是否放行;
|
|
36
|
+
- 拒绝原因本身是下一步的输入,而不是一句无关的备注。
|
|
37
|
+
|
|
38
|
+
这个插件在审批面板上加一个可选输入框,把原因送到模型能读到的地方,同时不新增用户消息、不额外起一轮对话。
|
|
39
|
+
|
|
40
|
+
## 效果
|
|
41
|
+
|
|
42
|
+
输入原因后点「拒绝」,模型收到的 tool result 末尾会多出一段:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
【审批请求已拒绝】原因:先不要动系统目录,把 diff 发给我看。
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| 操作 | 结果 |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 输入原因 → 拒绝 | 原因附在该工具的 tool result 里,模型在同一轮读到 |
|
|
51
|
+
| 不输入 → 拒绝 | 与原生拒绝完全一致,不发送任何额外内容 |
|
|
52
|
+
| 允许一次 | 与原生一致,输入框内容不会被发送 |
|
|
53
|
+
|
|
54
|
+
前缀随界面语言变化:中文为 `【审批请求已拒绝】原因:`,英文为 `[Approval request rejected] Reason: `。
|
|
55
|
+
|
|
56
|
+
## 安装
|
|
57
|
+
|
|
58
|
+
从 GitHub 安装,或克隆到本地开发和调试。
|
|
59
|
+
|
|
60
|
+
### 前置条件
|
|
61
|
+
|
|
62
|
+
- **DSH Desktop**。插件在 DSH Desktop 内嵌的审批界面中运行,本项目也只在 Desktop 中验证;`dsh-TUI` 不使用这个面板。
|
|
63
|
+
- 在 DSH Desktop `0.2.1-rc.5` 上验证通过;其他版本见[兼容性](#兼容性)。
|
|
64
|
+
- 从 GitHub 安装需要本机有 **Git**,并且能访问 GitHub。
|
|
65
|
+
- 安装会修改当前 profile,**影响该 profile 下的所有会话**。
|
|
66
|
+
- 插件以 Host 插件身份在 DSH 进程内运行;请只从本仓库或你信任的副本安装。
|
|
67
|
+
|
|
68
|
+
### 方式一:从 GitHub 安装(推荐)
|
|
69
|
+
|
|
70
|
+
**在 DSH Desktop 中:**
|
|
71
|
+
|
|
72
|
+
1. 打开 DSH Desktop,进入左侧 **Plugins(插件)** 页面。
|
|
73
|
+
2. 点 **Add plugin(添加插件)** → **Install a third-party plugin(安装第三方插件)**。
|
|
74
|
+
3. 填入仓库地址:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
git+https://github.com/Jorgen233/dsh-why-reject-me.git
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
4. 点 **Install(安装)**。
|
|
81
|
+
5. 安装完成后如果出现 **Enable now(立即启用)**,点它;如果关闭了对话框,在 **Installed plugins** 中确认 `dsh-why-reject-me` 已打开。
|
|
82
|
+
6. 按界面提示重启 DSH Desktop。Host 半在启动时加载,只刷新 Desktop 页面可能不够。
|
|
83
|
+
|
|
84
|
+
**让 Agent 安装:**
|
|
85
|
+
|
|
86
|
+
在启用了 `plugin_manager` 工具的会话(默认是 Creator 模式)中,直接发:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
请在我的 DSH profile 中安装这个插件:
|
|
90
|
+
plugin_manager action=install_bundle target=git+https://github.com/Jorgen233/dsh-why-reject-me.git
|
|
91
|
+
|
|
92
|
+
安装完成后确认 bundle 已启用,并告诉我是否需要重启 DSH Desktop。
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
如果当前会话没有 `plugin_manager`,改用 DSH Desktop 的 Plugins 页面。
|
|
96
|
+
|
|
97
|
+
### 方式二:本地克隆 / 开发
|
|
98
|
+
|
|
99
|
+
适合需要改代码或调试插件,并且已经能取得仓库副本的情况。
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/Jorgen233/dsh-why-reject-me.git
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
然后在 **Plugins → Add plugin** 中填入克隆目录的**绝对路径**,例如:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
G:\Dev\dsh-why-reject-me
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
本地路径会以 `link:` 方式装入 profile,修改 `client.js` 后刷新 Desktop 页面即可生效;修改 `index.js` 后需要重启 Desktop。
|
|
112
|
+
|
|
113
|
+
> 本地安装要先有仓库副本。如果 GitHub 无法直接访问,需要先通过代理、镜像或其他方式取得副本,再填本地路径;直接 `git clone` 仓库地址仍然需要能访问 GitHub。
|
|
114
|
+
|
|
115
|
+
### 更新
|
|
116
|
+
|
|
117
|
+
GitHub 安装:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
plugin_manager action=remove_bundle target=dsh-why-reject-me
|
|
121
|
+
plugin_manager action=install_bundle target=git+https://github.com/Jorgen233/dsh-why-reject-me.git
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
在 DSH Desktop 中等价于:在 **Plugins** 里卸载后重新 **Add plugin**。本地路径安装同理,把 target 换回对应的路径即可。更新 Host 半后需要重启 DSH Desktop。
|
|
125
|
+
|
|
126
|
+
### 确认安装
|
|
127
|
+
|
|
128
|
+
- **Plugins** 列表中能看到 `dsh-why-reject-me`,且开关为开启状态;
|
|
129
|
+
- 把会话权限切到 `danger-full-access`,让 Agent 运行一条只读命令,或触发任意会弹出审批的操作;
|
|
130
|
+
- 审批面板中应出现「拒绝原因(可选)」输入框;
|
|
131
|
+
- 输入原因并拒绝后,展开对应的工具结果卡片,应能看到原因文本。
|
|
132
|
+
|
|
133
|
+
## 使用
|
|
134
|
+
|
|
135
|
+
1. Agent 请求越权执行,审批面板出现;
|
|
136
|
+
2. 在「拒绝原因(可选)」中填写原因,可以留空;
|
|
137
|
+
3. 点「拒绝」,或按 `Ctrl/⌘ + Enter`;
|
|
138
|
+
4. 原因会先提交给 Host,随后审批才以 `rejected` 结束;
|
|
139
|
+
5. 模型在同一个 tool result 中读到原因,并据此继续。
|
|
140
|
+
|
|
141
|
+
原因只在**拒绝**时发送。点「允许一次」不会发送输入框里的内容。
|
|
142
|
+
|
|
143
|
+
如果原因提交失败,审批不会自动结束,面板会显示错误并允许重试;此时可以重试,或清空原因后直接拒绝。
|
|
144
|
+
|
|
145
|
+
## 快捷键
|
|
146
|
+
|
|
147
|
+
| 按键 | 行为 |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| `Enter`(焦点不在输入框或按钮上) | 允许一次 |
|
|
150
|
+
| `Ctrl/⌘ + Enter` | 拒绝并附上原因 |
|
|
151
|
+
| `Esc`(焦点在输入框且有内容) | 让输入框失焦,避免误触拒绝 |
|
|
152
|
+
| `Esc`(输入框为空) | 直接拒绝 |
|
|
153
|
+
|
|
154
|
+
输入法组合输入期间按 `Enter` 不会触发审批动作。
|
|
155
|
+
|
|
156
|
+
## 工作原理
|
|
157
|
+
|
|
158
|
+
1. 客户端接管 `conversation.composer` 上的审批面板,只在「待处理交互是审批」且「不是子代理会话」时生效;
|
|
159
|
+
2. 点拒绝且原因非空时,客户端把 `{ sessionId, callId, reason, prefix }` POST 到 Host 的 `/api/dsh-why-reject-me/reason`;
|
|
160
|
+
3. Host 在内存中按 `Session + Tool call` 暂存原因,5 分钟内未匹配到拒绝结果就丢弃;
|
|
161
|
+
4. 客户端调用原审批对象的 `answer('rejected')`;
|
|
162
|
+
5. 拒绝结果产生后,Host 的 `tools/post-execute` 监听器把原因追加到这条 tool result 的 content;
|
|
163
|
+
6. 模型像读 `ask_user_question` 的答案一样读到它——它是普通工具结果,不是一条 `user/message`,也不会单独起一轮。
|
|
164
|
+
|
|
165
|
+
插件不修改任何 DSH 自带包,Host 半只注册一个同源路由和一个 `tools/post-execute` 监听器。
|
|
166
|
+
|
|
167
|
+
## 兼容性
|
|
168
|
+
|
|
169
|
+
- 验证环境:DSH Desktop `0.2.1-rc.2` 和 `0.2.1-rc.5`;两个版本都做过真实审批验证,其他运行方式未验证;
|
|
170
|
+
- 依赖以下公开面:
|
|
171
|
+
- `conversation.composer` 链式插槽与 `priority`;
|
|
172
|
+
- `PendingApproval.kind / answer / callId / key`;
|
|
173
|
+
- `ctx.connection.fetch.register`;
|
|
174
|
+
- `tools/post-execute` waterfall;
|
|
175
|
+
- `SessionSnapshot.subagent`。
|
|
176
|
+
- DSH 大版本升级后,如果这些公开面发生变化,插件可能需要同步适配。升级 DSH 后若审批面板异常,先禁用本插件,确认是否为兼容性问题。
|
|
177
|
+
|
|
178
|
+
## 已知限制
|
|
179
|
+
|
|
180
|
+
- 原因会作为 tool result 的一部分进入会话记录(`tool/result`),并随上下文发送给模型提供商。这是「模型可见」的必要代价,无法既让模型读到、又完全不落盘;
|
|
181
|
+
- 展开被拒绝工具的结果卡片时,可以看到这段原因;
|
|
182
|
+
- **子代理会话不适用**:运行时会在子代理上下文中自动拒绝提权,插件不会接管这类审批,因此没有原因输入框,拒绝文案仍是运行时原文(`the user rejected ...`,与人工拒绝同文)。这是 DSH 核心策略,不是本插件能改的;
|
|
183
|
+
- 原因上限 2000 字;Host 暂存 5 分钟未匹配到拒绝结果就丢弃;
|
|
184
|
+
- 未发送的草稿按 `Session + Tool call` 保留 30 分钟:切到其他会话再回来不会丢字,但刷新 Desktop 页面会丢(只存在页面内存里);
|
|
185
|
+
- 只处理权限审批,不处理 `ask_user_question` 或其他确认框;
|
|
186
|
+
- 原因只是附加文本,模型会读到,但不保证一定照做;
|
|
187
|
+
- 插件通过 `priority: 0` 接管审批面板。如果同时安装了其他抢占 `conversation.composer` 的审批插件,可能只有其中一个生效。
|
|
188
|
+
|
|
189
|
+
## 卸载与回滚
|
|
190
|
+
|
|
191
|
+
在 **Plugins** 页面把 `dsh-why-reject-me` 关闭或卸载,然后重启 DSH Desktop。
|
|
192
|
+
|
|
193
|
+
也可以用 Agent 工具:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
plugin_manager action=set_bundle target=dsh-why-reject-me enabled=false
|
|
197
|
+
# 或
|
|
198
|
+
plugin_manager action=remove_bundle target=dsh-why-reject-me
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
卸载后审批面板恢复为 DSH 自带样式,已经写入会话记录的原因不会被删除。
|
|
202
|
+
|
|
203
|
+
## 故障排查
|
|
204
|
+
|
|
205
|
+
| 现象 | 处理 |
|
|
206
|
+
| --- | --- |
|
|
207
|
+
| 安装后审批面板没有变化 | 确认 bundle 已启用;重启 DSH Desktop,而不是只刷新 Desktop 页面 |
|
|
208
|
+
| 有输入框,但模型没看到原因 | 确认点的是「拒绝」且原因非空;原因提交失败时面板会报错并停在审批上;展开工具结果卡片查看原文 |
|
|
209
|
+
| 点拒绝后一直停在「正在提交原因…」 | Host 路由没有响应;重启 Desktop,并检查是否有代理或安全软件拦截同源 POST |
|
|
210
|
+
| 子代理会话没有输入框 | 预期行为,见[已知限制](#已知限制) |
|
|
211
|
+
| 刷新 Desktop 页面后草稿丢了 | 预期行为,草稿只存在页面内存中 |
|
|
212
|
+
| GitHub 安装失败或超时 | 确认本机有 Git、能访问 GitHub;或先取得仓库副本后填本地路径 |
|
|
213
|
+
| 插件列表显示的不是英文名 | 升级到 `0.3.2` 或更新版本,并重启 Desktop 清除旧元数据缓存 |
|
|
214
|
+
| 和其他审批插件冲突 | 只保留一个接管 `conversation.composer` 的审批插件 |
|
|
215
|
+
|
|
216
|
+
## 开发与测试
|
|
217
|
+
|
|
218
|
+
插件是纯 ESM JavaScript,不需要构建,也没有运行时依赖。测试需要 Node.js 20 或更新版本,只使用 Node 内置模块,克隆后无需先安装依赖。
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
git clone https://github.com/Jorgen233/dsh-why-reject-me.git
|
|
222
|
+
cd dsh-why-reject-me
|
|
223
|
+
npm test
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`npm test` 依次运行三个冒烟测试:
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
node test/smoke-client.mjs
|
|
230
|
+
node test/smoke-panel.mjs
|
|
231
|
+
node test/smoke-host.mjs
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| 文件 | 作用 |
|
|
235
|
+
| --- | --- |
|
|
236
|
+
| `index.js` | Host 半:原因暂存路由、`tools/post-execute` 追加逻辑 |
|
|
237
|
+
| `client.js` | 客户端半(Desktop 内嵌页面):审批面板、草稿、POST、快捷键 |
|
|
238
|
+
| `cordis.patch.yml` | 向 profile 插入插件行 |
|
|
239
|
+
| `locale/` | 插件列表的显示元数据 |
|
|
240
|
+
| `test/` | 不依赖 DSH 运行时的冒烟测试 |
|
|
241
|
+
|
|
242
|
+
三个测试分别覆盖:插槽注册与 POST 形状、面板交互与草稿生命周期、Host 路由校验与 tool result 追加。真实 Desktop 行为仍需安装后手测:
|
|
243
|
+
|
|
244
|
+
1. 触发一次需要审批的操作;
|
|
245
|
+
2. 输入原因并拒绝,确认 tool result 末尾出现原因;
|
|
246
|
+
3. 再次触发审批,留空拒绝,确认与原生一致;
|
|
247
|
+
4. 输入原因后切到其他会话再回来,确认草稿还在。
|
|
248
|
+
|
|
249
|
+
## 许可证
|
|
250
|
+
|
|
251
|
+
[MIT](LICENSE),Copyright (c) 2026 Jorgen233。
|
|
252
|
+
|
|
253
|
+
## 关于本项目
|
|
254
|
+
|
|
255
|
+
本项目完全由 DeepSeek 搭建和维护。
|
|
256
|
+
|
|
257
|
+
它是第三方社区插件,不是 DSH 官方组件。欢迎通过 GitHub Issues 反馈问题;涉及 DSH 公开面的兼容性调整,会随 DSH 版本变化更新。
|
package/client.js
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-why-reject-me',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
/** Locale namespace owned by this plugin. */
|
|
8
|
+
const NS = 'why-reject-me'
|
|
9
|
+
/** Hard cap for one rejection reason; the textarea enforces the same value. */
|
|
10
|
+
const MAX_REASON_LENGTH = 2000
|
|
11
|
+
/** Keep an unsent draft while the user switches Sessions and comes back. */
|
|
12
|
+
const DRAFT_TTL_MS = 30 * 60 * 1000
|
|
13
|
+
/** Unique style tag id so HMR reloads can find (and update) the sheet. */
|
|
14
|
+
const STYLE_ID = 'dsh-why-reject-me/panel.css'
|
|
15
|
+
|
|
16
|
+
/** @type {Map<string, { text: string, expiresAt: number }>} */
|
|
17
|
+
const drafts = new Map()
|
|
18
|
+
|
|
19
|
+
/** One draft identity: Session + Tool call, with the pending key as fallback. */
|
|
20
|
+
function draftKey(pending) {
|
|
21
|
+
if (pending === null || typeof pending !== 'object') return undefined
|
|
22
|
+
const sessionId = typeof pending.sessionId === 'string' && pending.sessionId.length > 0
|
|
23
|
+
? pending.sessionId
|
|
24
|
+
: undefined
|
|
25
|
+
if (sessionId === undefined) return undefined
|
|
26
|
+
if (typeof pending.callId === 'string' && pending.callId.length > 0) {
|
|
27
|
+
return `${sessionId}\u0000${pending.callId}`
|
|
28
|
+
}
|
|
29
|
+
if (typeof pending.key === 'string' && pending.key.length > 0) {
|
|
30
|
+
return `${sessionId}\u0000${pending.key}`
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read one draft, dropping it once its approval is long gone. */
|
|
36
|
+
function readDraft(key) {
|
|
37
|
+
if (key === undefined) return ''
|
|
38
|
+
const entry = drafts.get(key)
|
|
39
|
+
if (entry === undefined) return ''
|
|
40
|
+
if (entry.expiresAt <= Date.now()) {
|
|
41
|
+
drafts.delete(key)
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
return entry.text
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Store one draft; an emptied field removes the entry. */
|
|
48
|
+
function writeDraft(key, text) {
|
|
49
|
+
if (key === undefined) return
|
|
50
|
+
if (text.length === 0) {
|
|
51
|
+
drafts.delete(key)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const now = Date.now()
|
|
55
|
+
for (const [keyOfEntry, entry] of drafts) {
|
|
56
|
+
if (entry.expiresAt <= now) drafts.delete(keyOfEntry)
|
|
57
|
+
}
|
|
58
|
+
drafts.set(key, { text, expiresAt: now + DRAFT_TTL_MS })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop one draft once its approval is answered. */
|
|
62
|
+
function clearDraft(key) {
|
|
63
|
+
if (key !== undefined) drafts.delete(key)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Simplified Chinese copy. */
|
|
67
|
+
const zh = {
|
|
68
|
+
waiting: '等待审批',
|
|
69
|
+
sending: '正在提交原因…',
|
|
70
|
+
detailAria: '审批详情',
|
|
71
|
+
escalation: '工具 {toolName} 请求越权执行',
|
|
72
|
+
reject: '拒绝',
|
|
73
|
+
allowOnce: '允许一次',
|
|
74
|
+
reasonLabel: '拒绝原因(可选)',
|
|
75
|
+
reasonPrefix: '【审批请求已拒绝】原因:',
|
|
76
|
+
reasonPlaceholder: '写下拒绝的理由,会附在拒绝结果里交给模型;留空则只发送拒绝。',
|
|
77
|
+
reasonHint: 'Ctrl/⌘ + Enter 拒绝并附上原因',
|
|
78
|
+
sendFailed: '原因提交失败:{message}',
|
|
79
|
+
rejectFailed: '拒绝没有送达:{message}',
|
|
80
|
+
rejecting: '正在拒绝…',
|
|
81
|
+
allowing: '正在允许…',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** English copy, checked against the Chinese key set. */
|
|
85
|
+
const en = {
|
|
86
|
+
waiting: 'Waiting for approval',
|
|
87
|
+
sending: 'Submitting rejection reason…',
|
|
88
|
+
detailAria: 'Approval details',
|
|
89
|
+
escalation: 'Tool {toolName} requests privileged execution',
|
|
90
|
+
reject: 'Reject',
|
|
91
|
+
allowOnce: 'Allow once',
|
|
92
|
+
reasonLabel: 'Rejection reason (optional)',
|
|
93
|
+
reasonPrefix: '[Approval request rejected] Reason: ',
|
|
94
|
+
reasonPlaceholder: 'Explain why you reject this request. It is attached to the rejection result for the model; leave empty to reject without a reason.',
|
|
95
|
+
reasonHint: 'Ctrl/⌘ + Enter rejects and attaches the reason',
|
|
96
|
+
sendFailed: 'Could not submit the reason: {message}',
|
|
97
|
+
rejectFailed: 'The rejection was not delivered: {message}',
|
|
98
|
+
rejecting: 'Rejecting…',
|
|
99
|
+
allowing: 'Allowing…',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Panel stylesheet, adapted from the shipped approval panel and primitives
|
|
104
|
+
* Button so the takeover looks like the host surface. Class names are
|
|
105
|
+
* prefixed with `dwrm_` to avoid colliding with any shipped stylesheet.
|
|
106
|
+
*/
|
|
107
|
+
const css = {
|
|
108
|
+
root: 'dwrm_root',
|
|
109
|
+
card: 'dwrm_card',
|
|
110
|
+
strip: 'dwrm_strip',
|
|
111
|
+
dot: 'dwrm_dot',
|
|
112
|
+
body: 'dwrm_body',
|
|
113
|
+
headline: 'dwrm_headline',
|
|
114
|
+
command: 'dwrm_command',
|
|
115
|
+
reasonLabel: 'dwrm_reasonLabel',
|
|
116
|
+
reason: 'dwrm_reason',
|
|
117
|
+
hint: 'dwrm_hint',
|
|
118
|
+
error: 'dwrm_error',
|
|
119
|
+
actionRow: 'dwrm_actionRow',
|
|
120
|
+
button: 'dwrm_btn',
|
|
121
|
+
medium: 'dwrm_md',
|
|
122
|
+
outline: 'dwrm_outline',
|
|
123
|
+
primary: 'dwrm_primary',
|
|
124
|
+
reject: 'dwrm_reject',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const STYLE_TEXT = `
|
|
128
|
+
.dwrm_root{padding:8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;flex-direction:column;align-items:center;display:flex}
|
|
129
|
+
.dwrm_card{width:100%;max-width:var(--dsh-chat-content-width);border:1px solid var(--dsw-alias-state-warn-secondary);border-radius:var(--dsw-radius-xl);background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv2);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);overflow:hidden}
|
|
130
|
+
.dwrm_strip{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);align-items:center;gap:8px;padding:10px 16px;font-size:13px;line-height:18px;display:flex}
|
|
131
|
+
.dwrm_dot{width:8px;height:8px;border-radius:999px;corner-shape:round;background:currentColor;flex:none}
|
|
132
|
+
.dwrm_dot[data-state=sending]{animation:dwrm_pulse 1s ease-in-out infinite}
|
|
133
|
+
@keyframes dwrm_pulse{0%,100%{opacity:.35}50%{opacity:1}}
|
|
134
|
+
.dwrm_body{box-sizing:border-box;max-height:var(--dsh-composer-text-max-height);flex-direction:column;gap:6px;padding:12px 16px 0;display:flex;overflow-y:auto}
|
|
135
|
+
.dwrm_headline{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:500;line-height:24px}
|
|
136
|
+
.dwrm_command{color:var(--dsw-alias-label-tertiary);font-family:var(--ds-font-family-code);word-break:break-all;font-size:13px;line-height:20px}
|
|
137
|
+
.dwrm_reasonLabel{margin-top:6px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
138
|
+
.dwrm_reason{box-sizing:border-box;width:100%;min-height:56px;max-height:160px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:19px;resize:vertical}
|
|
139
|
+
.dwrm_reason::placeholder{color:var(--dsw-alias-label-secondary)}
|
|
140
|
+
.dwrm_reason:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}
|
|
141
|
+
.dwrm_reason:disabled{opacity:.6;cursor:not-allowed}
|
|
142
|
+
.dwrm_hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
|
|
143
|
+
.dwrm_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;word-break:break-word}
|
|
144
|
+
.dwrm_actionRow{justify-content:flex-end;gap:8px;padding:14px 16px;display:flex}
|
|
145
|
+
.dwrm_btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;border:none;border-radius:var(--dsw-radius-md);cursor:pointer;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);background:transparent;padding:0 14px}
|
|
146
|
+
.dwrm_btn:disabled{cursor:not-allowed;opacity:.4}
|
|
147
|
+
.dwrm_md{height:36px}
|
|
148
|
+
.dwrm_outline{border:.5px solid var(--dsw-alias-border-l3);background:transparent}
|
|
149
|
+
.dwrm_outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
150
|
+
.dwrm_reject:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-color:transparent}
|
|
151
|
+
.dwrm_primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
|
|
152
|
+
.dwrm_primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
/** Install (or refresh) the plugin stylesheet exactly once per page. */
|
|
156
|
+
function installStyle(ctx) {
|
|
157
|
+
if (typeof document === 'undefined') return
|
|
158
|
+
const selector = 'style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']'
|
|
159
|
+
const existing = document.querySelector(selector)
|
|
160
|
+
if (existing !== null) {
|
|
161
|
+
existing.textContent = STYLE_TEXT
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
const tag = document.createElement('style')
|
|
165
|
+
tag.dataset.plugin = 'dsh-why-reject-me'
|
|
166
|
+
tag.dataset.pluginCss = STYLE_ID
|
|
167
|
+
tag.textContent = STYLE_TEXT
|
|
168
|
+
ctx.effect(() => {
|
|
169
|
+
if (!tag.isConnected) document.head.appendChild(tag)
|
|
170
|
+
return () => {
|
|
171
|
+
tag.remove()
|
|
172
|
+
}
|
|
173
|
+
}, 'dsh-why-reject-me: panel stylesheet')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolve one failure into a short, localizable message. */
|
|
177
|
+
function messageOf(cause) {
|
|
178
|
+
if (cause instanceof Error && cause.message.length > 0) return cause.message
|
|
179
|
+
const text = String(cause)
|
|
180
|
+
return text.length > 0 ? text : 'unknown error'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Hand one rejection reason to the Host half.
|
|
185
|
+
*
|
|
186
|
+
* The Host parks it in memory and appends it to the rejected Tool call's
|
|
187
|
+
* `tool/result`; the model reads it there, exactly like an
|
|
188
|
+
* `ask_user_question` answer. No user message is created.
|
|
189
|
+
*/
|
|
190
|
+
async function sendReason(pending, text, prefix) {
|
|
191
|
+
const reason = String(text ?? '').trim().slice(0, MAX_REASON_LENGTH)
|
|
192
|
+
if (reason.length === 0) return
|
|
193
|
+
const sessionId = pending !== null && typeof pending === 'object' ? pending.sessionId : undefined
|
|
194
|
+
const callId = pending !== null && typeof pending === 'object' ? pending.callId : undefined
|
|
195
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
196
|
+
throw new Error('the pending approval has no Session identity')
|
|
197
|
+
}
|
|
198
|
+
if (typeof callId !== 'string' || callId.length === 0) {
|
|
199
|
+
throw new Error('the pending approval has no Tool call identity')
|
|
200
|
+
}
|
|
201
|
+
const marker = typeof prefix === 'string' ? prefix.slice(0, 120) : undefined
|
|
202
|
+
const response = await fetch('/api/dsh-why-reject-me/reason', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
sessionId,
|
|
207
|
+
callId,
|
|
208
|
+
reason,
|
|
209
|
+
...(marker === undefined ? {} : { prefix: marker }),
|
|
210
|
+
}),
|
|
211
|
+
})
|
|
212
|
+
if (response.ok) return
|
|
213
|
+
let detail = ''
|
|
214
|
+
try {
|
|
215
|
+
const payload = await response.json()
|
|
216
|
+
if (payload !== null && typeof payload === 'object' && typeof payload.error === 'string') {
|
|
217
|
+
detail = payload.error
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// keep the status-only message
|
|
221
|
+
}
|
|
222
|
+
throw new Error(detail.length > 0 ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether one Session pending interaction is the approval takeover we own. */
|
|
226
|
+
function isApproval(value) {
|
|
227
|
+
return value !== null
|
|
228
|
+
&& typeof value === 'object'
|
|
229
|
+
&& value.kind === 'approval'
|
|
230
|
+
&& typeof value.answer === 'function'
|
|
231
|
+
&& typeof value.sessionId === 'string'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether the addressed Session is a subagent conversation.
|
|
236
|
+
*
|
|
237
|
+
* Subagent sessions keep the shipped approval behavior: the Host disables
|
|
238
|
+
* approval prompts there and auto-rejects escalation, so this plugin must
|
|
239
|
+
* not claim their pending interactions (and never asks for a reason).
|
|
240
|
+
* @param session - current Session snapshot from the composer owner props.
|
|
241
|
+
*/
|
|
242
|
+
function isSubagentSession(session) {
|
|
243
|
+
return session !== undefined && session !== null && session.subagent != null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render the command text of the Tool call correlated with an approval.
|
|
248
|
+
* Mirrors the shipped `ApprovalCommand`, using the standard `useChat` prop
|
|
249
|
+
* instead of the shipped detail slot so this entry declares no children.
|
|
250
|
+
*/
|
|
251
|
+
function CommandDetail(props) {
|
|
252
|
+
const command = props.useChat((snapshot) => {
|
|
253
|
+
for (const node of snapshot.nodes.values()) {
|
|
254
|
+
const root = node.kind === 'tool-call' ? node.data.root : undefined
|
|
255
|
+
if (root !== undefined && root.callId === props.callId && !('kind' in root) && root.phase === 'start') {
|
|
256
|
+
try {
|
|
257
|
+
const args = JSON.parse(root.argsRaw)
|
|
258
|
+
return typeof args.command === 'string' ? args.command : null
|
|
259
|
+
} catch {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
265
|
+
})
|
|
266
|
+
return command === null || command === undefined
|
|
267
|
+
? null
|
|
268
|
+
: h('div', { className: css.command }, command)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One pending approval with an optional rejection reason.
|
|
273
|
+
*
|
|
274
|
+
* Remounted per `pending.key` by {@link WhyRejectPanel}, so a new approval
|
|
275
|
+
* never inherits the previous draft or error.
|
|
276
|
+
*/
|
|
277
|
+
function ApprovalReasonPanel(props) {
|
|
278
|
+
const pending = props.matched
|
|
279
|
+
const t = props.t
|
|
280
|
+
const draftId = draftKey(pending)
|
|
281
|
+
const [reason, setReason] = React.useState(readDraft(draftId))
|
|
282
|
+
const [phase, setPhase] = React.useState(null)
|
|
283
|
+
const [error, setError] = React.useState(null)
|
|
284
|
+
const waiting = React.useRef(false)
|
|
285
|
+
const active = React.useRef(true)
|
|
286
|
+
const sentReason = React.useRef(null)
|
|
287
|
+
const busy = phase !== null
|
|
288
|
+
|
|
289
|
+
React.useEffect(() => {
|
|
290
|
+
active.current = true
|
|
291
|
+
return () => {
|
|
292
|
+
active.current = false
|
|
293
|
+
}
|
|
294
|
+
}, [])
|
|
295
|
+
|
|
296
|
+
React.useEffect(() => {
|
|
297
|
+
setReason(readDraft(draftId))
|
|
298
|
+
}, [draftId])
|
|
299
|
+
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
if (pending.answerable === false) clearDraft(draftId)
|
|
302
|
+
}, [pending.answerable, draftId])
|
|
303
|
+
|
|
304
|
+
const settle = async (outcome) => {
|
|
305
|
+
if (waiting.current || pending.answerable === false) return
|
|
306
|
+
waiting.current = true
|
|
307
|
+
const text = reason.trim().slice(0, MAX_REASON_LENGTH)
|
|
308
|
+
const sendsReason = outcome === 'rejected' && text.length > 0 && sentReason.current !== text
|
|
309
|
+
setPhase(outcome === 'allowed-once' ? 'allowing' : sendsReason ? 'sending' : 'rejecting')
|
|
310
|
+
setError(null)
|
|
311
|
+
let stage = 'reason'
|
|
312
|
+
try {
|
|
313
|
+
if (sendsReason) {
|
|
314
|
+
await props.sendReason(pending, text)
|
|
315
|
+
sentReason.current = text
|
|
316
|
+
if (active.current) setPhase('rejecting')
|
|
317
|
+
}
|
|
318
|
+
stage = 'answer'
|
|
319
|
+
await pending.answer(outcome)
|
|
320
|
+
clearDraft(draftId)
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
if (!active.current) return
|
|
323
|
+
waiting.current = false
|
|
324
|
+
setPhase(null)
|
|
325
|
+
setError(t(stage === 'reason' ? 'sendFailed' : 'rejectFailed', { message: messageOf(cause) }))
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const keydown = (event) => {
|
|
330
|
+
if (event.defaultPrevented || waiting.current) return
|
|
331
|
+
const element = event.target
|
|
332
|
+
const field = element !== null && element.closest !== undefined
|
|
333
|
+
? element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
|
|
334
|
+
: null
|
|
335
|
+
const composing = (event.nativeEvent !== undefined && event.nativeEvent.isComposing === true) || event.keyCode === 229
|
|
336
|
+
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
|
337
|
+
if (composing) return
|
|
338
|
+
event.preventDefault()
|
|
339
|
+
event.stopPropagation()
|
|
340
|
+
settle('rejected')
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (event.key === 'Escape') {
|
|
344
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
|
|
345
|
+
if (field !== null && reason.trim().length > 0) {
|
|
346
|
+
event.preventDefault()
|
|
347
|
+
field.blur()
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
event.preventDefault()
|
|
351
|
+
event.stopPropagation()
|
|
352
|
+
settle('rejected')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (event.key !== 'Enter') return
|
|
356
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || composing) return
|
|
357
|
+
if (element !== null && element.closest !== undefined && element.closest('button, a[href], [role="button"]') !== null) return
|
|
358
|
+
if (field !== null) return
|
|
359
|
+
event.preventDefault()
|
|
360
|
+
event.stopPropagation()
|
|
361
|
+
settle('allowed-once')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const headline = pending.displayReason === undefined
|
|
365
|
+
? (pending.reason === undefined ? t('escalation', { toolName: pending.toolName }) : pending.reason)
|
|
366
|
+
: props.resolveReason(pending.displayReason)
|
|
367
|
+
const detail = pending.callId !== undefined && typeof props.useChat === 'function'
|
|
368
|
+
? h(CommandDetail, { useChat: props.useChat, callId: pending.callId })
|
|
369
|
+
: null
|
|
370
|
+
const disabled = busy || pending.answerable === false
|
|
371
|
+
const stripText = phase === 'sending'
|
|
372
|
+
? t('sending')
|
|
373
|
+
: phase === 'allowing'
|
|
374
|
+
? t('allowing')
|
|
375
|
+
: phase === 'rejecting'
|
|
376
|
+
? t('rejecting')
|
|
377
|
+
: t('waiting')
|
|
378
|
+
|
|
379
|
+
return h('div', {
|
|
380
|
+
className: css.root,
|
|
381
|
+
'data-approval-key': pending.key,
|
|
382
|
+
'data-why-reject-me': 'approval',
|
|
383
|
+
'aria-busy': busy,
|
|
384
|
+
onKeyDown: keydown,
|
|
385
|
+
}, h('div', { className: css.card }, [
|
|
386
|
+
h('div', { className: css.strip }, [
|
|
387
|
+
h('span', { className: css.dot, 'data-state': phase === 'sending' ? 'sending' : 'waiting', 'aria-hidden': true }),
|
|
388
|
+
stripText,
|
|
389
|
+
]),
|
|
390
|
+
h('div', {
|
|
391
|
+
className: css.body,
|
|
392
|
+
'data-approval-scroll': '',
|
|
393
|
+
tabIndex: 0,
|
|
394
|
+
role: 'group',
|
|
395
|
+
'aria-label': t('detailAria'),
|
|
396
|
+
}, [
|
|
397
|
+
h('div', { className: css.headline }, headline),
|
|
398
|
+
detail,
|
|
399
|
+
h('div', { className: css.reasonLabel }, t('reasonLabel')),
|
|
400
|
+
h('textarea', {
|
|
401
|
+
className: css.reason,
|
|
402
|
+
value: reason,
|
|
403
|
+
rows: 2,
|
|
404
|
+
maxLength: MAX_REASON_LENGTH,
|
|
405
|
+
placeholder: t('reasonPlaceholder'),
|
|
406
|
+
'aria-label': t('reasonLabel'),
|
|
407
|
+
disabled,
|
|
408
|
+
onChange: (event) => {
|
|
409
|
+
const next = event.target.value
|
|
410
|
+
setReason(next)
|
|
411
|
+
writeDraft(draftId, next)
|
|
412
|
+
if (error !== null) setError(null)
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
h('div', { className: css.hint }, t('reasonHint')),
|
|
416
|
+
error === null ? null : h('div', { className: css.error, role: 'alert' }, error),
|
|
417
|
+
]),
|
|
418
|
+
h('div', { className: css.actionRow }, [
|
|
419
|
+
h('button', {
|
|
420
|
+
type: 'button',
|
|
421
|
+
className: css.button + ' ' + css.medium + ' ' + css.outline + ' ' + css.reject,
|
|
422
|
+
disabled,
|
|
423
|
+
onClick: () => {
|
|
424
|
+
settle('rejected')
|
|
425
|
+
},
|
|
426
|
+
}, t('reject')),
|
|
427
|
+
h('button', {
|
|
428
|
+
type: 'button',
|
|
429
|
+
className: css.button + ' ' + css.medium + ' ' + css.primary,
|
|
430
|
+
disabled,
|
|
431
|
+
onClick: () => {
|
|
432
|
+
settle('allowed-once')
|
|
433
|
+
},
|
|
434
|
+
}, t('allowOnce')),
|
|
435
|
+
]),
|
|
436
|
+
]))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Remount the panel whenever the pending approval changes. */
|
|
440
|
+
function WhyRejectPanel(props) {
|
|
441
|
+
const approval = props.matched
|
|
442
|
+
return h(ApprovalReasonPanel, { ...props, key: approval.key })
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Required Client services: slot registry and copy. */
|
|
446
|
+
const inject = ['slots', 'locale']
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Install the approval takeover.
|
|
450
|
+
* @param ctx - Client root context.
|
|
451
|
+
*/
|
|
452
|
+
function apply(ctx) {
|
|
453
|
+
installStyle(ctx)
|
|
454
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-why-reject-me: dictionaries')
|
|
455
|
+
ctx.slots.inject('conversation.composer', () => ctx.slots.register({
|
|
456
|
+
name: 'conversation.composer',
|
|
457
|
+
// Lower number wins in a chain: the shipped approval takeover is 1,
|
|
458
|
+
// and the ordinary composer entries decline while an approval is pending.
|
|
459
|
+
// Subagent conversations are explicitly left to the shipped behavior.
|
|
460
|
+
priority: 0,
|
|
461
|
+
select: ({ pendingInteraction, session }) => {
|
|
462
|
+
if (isSubagentSession(session)) return null
|
|
463
|
+
return isApproval(pendingInteraction) ? pendingInteraction : null
|
|
464
|
+
},
|
|
465
|
+
locale: NS,
|
|
466
|
+
inject: () => ({
|
|
467
|
+
resolveReason: (reason) => ctx.locale.resolveText(reason),
|
|
468
|
+
sendReason: (pending, text) => sendReason(pending, text, ctx.locale.bind(NS)('reasonPrefix')),
|
|
469
|
+
}),
|
|
470
|
+
}, WhyRejectPanel))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { inject, apply }
|
|
474
|
+
},
|
|
475
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Why you reject me">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#2F6BFF"/>
|
|
3
|
+
<path d="M18 18h28a6 6 0 0 1 6 6v14a6 6 0 0 1-6 6H32l-9 7v-7h-5a6 6 0 0 1-6-6V24a6 6 0 0 1 6-6Z" fill="#FFFFFF"/>
|
|
4
|
+
<path d="M23 28h18M23 36h10" stroke="#2F6BFF" stroke-width="4" stroke-linecap="round"/>
|
|
5
|
+
<circle cx="46" cy="44" r="9" fill="#E5484D" stroke="#FFFFFF" stroke-width="3"/>
|
|
6
|
+
<path d="m42.6 40.6 6.8 6.8m0-6.8-6.8 6.8" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-why-reject-me`.
|
|
3
|
+
*
|
|
4
|
+
* The Client half posts the rejection reason here before answering the
|
|
5
|
+
* approval. This half parks the text in memory, keyed by Session and Tool call,
|
|
6
|
+
* and appends it to that call's rejected `tool/result` through the
|
|
7
|
+
* `tools/post-execute` waterfall. The model therefore reads the reason as part
|
|
8
|
+
* of the tool result — the same channel `ask_user_question` uses — instead of a
|
|
9
|
+
* separate user message.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Same-origin route the Client half posts one reason to. */
|
|
13
|
+
const REASON_PATH = '/api/dsh-why-reject-me/reason'
|
|
14
|
+
/** Keep in step with the Client textarea. */
|
|
15
|
+
const MAX_REASON_LENGTH = 2000
|
|
16
|
+
/** A pending reason that never met its Tool result is dropped after this. */
|
|
17
|
+
const REASON_TTL_MS = 5 * 60 * 1000
|
|
18
|
+
|
|
19
|
+
/** @type {Map<string, { text: string, prefix: string, expiresAt: number }>} */
|
|
20
|
+
const reasons = new Map()
|
|
21
|
+
|
|
22
|
+
/** One key per Session and Tool call; both are opaque strings. */
|
|
23
|
+
function keyOf(sessionId, callId) {
|
|
24
|
+
return `${sessionId}\u0000${callId}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drop expired entries so an unanswered approval cannot leak text. */
|
|
28
|
+
function prune(now) {
|
|
29
|
+
for (const [key, value] of reasons) {
|
|
30
|
+
if (value.expiresAt <= now) reasons.delete(key)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One JSON response with a stable content type. */
|
|
35
|
+
function json(status, body) {
|
|
36
|
+
return new Response(JSON.stringify(body), {
|
|
37
|
+
status,
|
|
38
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Receive one reason from the Client.
|
|
44
|
+
* @param {Request} request - buffered same-origin POST body.
|
|
45
|
+
* @returns {Promise<Response>} accepted or a validation failure.
|
|
46
|
+
*/
|
|
47
|
+
async function receiveReason(request) {
|
|
48
|
+
let body
|
|
49
|
+
try {
|
|
50
|
+
body = await request.json()
|
|
51
|
+
} catch {
|
|
52
|
+
return json(400, { ok: false, error: 'invalid JSON body' })
|
|
53
|
+
}
|
|
54
|
+
if (body === null || typeof body !== 'object') {
|
|
55
|
+
return json(400, { ok: false, error: 'invalid body' })
|
|
56
|
+
}
|
|
57
|
+
const { sessionId, callId, reason, prefix } = body
|
|
58
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
59
|
+
return json(400, { ok: false, error: 'sessionId is required' })
|
|
60
|
+
}
|
|
61
|
+
if (typeof callId !== 'string' || callId.length === 0) {
|
|
62
|
+
return json(400, { ok: false, error: 'callId is required' })
|
|
63
|
+
}
|
|
64
|
+
if (typeof reason !== 'string') {
|
|
65
|
+
return json(400, { ok: false, error: 'reason is required' })
|
|
66
|
+
}
|
|
67
|
+
const text = reason.trim().slice(0, MAX_REASON_LENGTH)
|
|
68
|
+
if (text.length === 0) {
|
|
69
|
+
return json(200, { ok: true, stored: false })
|
|
70
|
+
}
|
|
71
|
+
const marker = typeof prefix === 'string' && prefix.length > 0
|
|
72
|
+
? prefix.slice(0, 120)
|
|
73
|
+
: '[approval rejected] reason: '
|
|
74
|
+
const now = Date.now()
|
|
75
|
+
prune(now)
|
|
76
|
+
reasons.set(keyOf(sessionId, callId), { text, prefix: marker, expiresAt: now + REASON_TTL_MS })
|
|
77
|
+
return json(200, { ok: true, stored: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Append the parked reason to the rejected Tool result.
|
|
82
|
+
*
|
|
83
|
+
* Registered with `prepend: true` so `next()` returns the decision every later
|
|
84
|
+
* listener produced; this listener only augments an accepted failed result.
|
|
85
|
+
* @param {object} exec - Tool execution carrying Session and call identity.
|
|
86
|
+
* @param {object} result - final Tool result before post-processing.
|
|
87
|
+
* @param {() => Promise<object>} next - downstream waterfall decision.
|
|
88
|
+
* @returns {Promise<object>} the accepted decision, with reason content added.
|
|
89
|
+
*/
|
|
90
|
+
async function appendReason(exec, result, next) {
|
|
91
|
+
const decision = await next()
|
|
92
|
+
if (decision.kind !== 'accept' || result.isError !== true) return decision
|
|
93
|
+
const sessionId = exec.agent?.id
|
|
94
|
+
if (typeof sessionId !== 'string' || typeof exec.callId !== 'string') return decision
|
|
95
|
+
const key = keyOf(sessionId, exec.callId)
|
|
96
|
+
const stored = reasons.get(key)
|
|
97
|
+
if (stored === undefined) return decision
|
|
98
|
+
if (stored.expiresAt <= Date.now()) {
|
|
99
|
+
reasons.delete(key)
|
|
100
|
+
return decision
|
|
101
|
+
}
|
|
102
|
+
reasons.delete(key)
|
|
103
|
+
const content = [...(decision.content ?? result.content), {
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: `\n\n${stored.prefix}${stored.text}`,
|
|
106
|
+
}]
|
|
107
|
+
return { ...decision, content }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The route and the Tool pipeline are both Host-owned services. */
|
|
111
|
+
export const inject = ['connection', 'tools']
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Install the reason route and the Tool-result augmentation.
|
|
115
|
+
* @param {object} ctx - Host plugin context.
|
|
116
|
+
*/
|
|
117
|
+
export function apply(ctx) {
|
|
118
|
+
ctx.connection.fetch.register({
|
|
119
|
+
path: REASON_PATH,
|
|
120
|
+
methods: ['POST'],
|
|
121
|
+
requestBody: 'buffered',
|
|
122
|
+
fetch: receiveReason,
|
|
123
|
+
})
|
|
124
|
+
ctx.on('tools/post-execute', appendReason, { prepend: true })
|
|
125
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-why-reject-me",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Adds an optional rejection reason to DSH permission approval prompts and delivers it with the rejected tool result.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jorgen233",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Jorgen233/dsh-why-reject-me.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Jorgen233/dsh-why-reject-me#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Jorgen233/dsh-why-reject-me/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"dsh",
|
|
17
|
+
"dsh-plugin",
|
|
18
|
+
"approval",
|
|
19
|
+
"permission",
|
|
20
|
+
"cordis"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node test/smoke-client.mjs && node test/smoke-panel.mjs && node test/smoke-host.mjs"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.js",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.js",
|
|
37
|
+
"client.js",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"locale/*.json",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
},
|
|
47
|
+
"client": {
|
|
48
|
+
"platform": "web",
|
|
49
|
+
"immediately": true,
|
|
50
|
+
"inject": [
|
|
51
|
+
"@deepseek-ai/dsh-client-locale",
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-approval",
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|