dsh-hitl 0.1.1 → 0.2.0
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/README.md +74 -19
- package/README.zh.md +66 -18
- package/index.js +50 -13
- package/lib/fields.js +224 -19
- package/lib/protocol.js +13 -0
- package/lib/resolve.js +67 -6
- package/package.json +1 -2
- package/tests/docs.test.js +21 -16
- package/tests/fields.test.js +119 -1
- package/tests/manifest.test.js +16 -0
- package/tests/resolve.test.js +62 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
# dsh-hitl · Human-in-the-loop for
|
|
1
|
+
# dsh-hitl · Human-in-the-loop for DeepSeek Harness
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-hitl) [](LICENSE) [](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
2
4
|
|
|
3
5
|
English | [中文](README.zh.md)
|
|
4
6
|
|
|
@@ -7,7 +9,7 @@ English | [中文](README.zh.md)
|
|
|
7
9
|
|
|
8
10
|
`dsh-hitl` is an installable DeepSeek Harness (DSH) plugin (bundle): **zero dependencies, zero build** — `index.js` (host half) + `client.js` (browser half) + `lib/` are plain JS that loads as it is. Install it, refresh the page once, and it works.
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
**[View Website](https://yunpengdon.github.io/dsh-hitl-landing/)**
|
|
11
13
|
|
|
12
14
|
## Features
|
|
13
15
|
|
|
@@ -18,18 +20,17 @@ English | [中文](README.zh.md)
|
|
|
18
20
|
- **Rejection with feedback**: 【Reject】 can carry a **feedback box**, and what you write there reaches the agent together with the rejection — it knows "don't do it" *and* "why not";
|
|
19
21
|
- **Configurable countdown**: a **countdown** plus three endings (**approve automatically / reject automatically / report the timeout to the agent**).
|
|
20
22
|
|
|
21
|
-

|
|
22
23
|
|
|
23
24
|
## Made for DSH
|
|
24
25
|
|
|
25
26
|
**Subagent pass-through: a tool call inside a subagent raises its approval request in the main conversation too.** Every tool call inside an in-process subagent (`spawn` / `fork`) goes through the same gate — the request is filed **both** under the child Session that made it and under the Session you are currently looking at: whichever conversation you are in, the card can appear there, labelled 「from subagent Session xxx」 with an 【Open that Session】 button.
|
|
26
27
|
(Out-of-process backends never reach this gate — see §6.1.)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
[See this card on the landing page →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
29
30
|
|
|
30
31
|
**A mini approval request even when you are not in a conversation.** Switch to the plugins page, open settings, or select no Session at all, and a notice floats at the top of the page, so HITL does not have to block the run while you are elsewhere (count, tool name, source Session, remaining time + 【Approve】【Reject】【Open that Session】).
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
[See the notice on the landing page →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
33
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
@@ -142,9 +143,10 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
142
143
|
| `reject.feedbackPrompt` | string | built-in copy | Feedback box placeholder |
|
|
143
144
|
| `reject.requireFeedback` | boolean | `false` | Feedback is required (an empty box is refused with a hint) |
|
|
144
145
|
| `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | What 【Modify】 means after an edit; see §5 |
|
|
145
|
-
| `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected
|
|
146
|
+
| `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected: `reject` = fail-closed; `wait` = wait indefinitely, so **pair it with a `countdown`** or that call hangs until its Session ends (the host warns at mount time); see §7.1 |
|
|
146
147
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
|
|
147
148
|
| `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
|
|
149
|
+
| `resolveTimeoutMs` | number | `5000` | Bound on resolving one **computed** field; a timeout or a throw fails that field alone, never the call |
|
|
148
150
|
|
|
149
151
|
### FieldSpec
|
|
150
152
|
|
|
@@ -153,6 +155,7 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
153
155
|
render: 'markdown', // markdown | text | diff | json | hidden
|
|
154
156
|
editable: true, // overrides the default editability
|
|
155
157
|
labels: ['irreversible'], // field-level labels
|
|
158
|
+
value: 'fixed copy', // this field's text: a literal, or (exec) => string | Promise<string>
|
|
156
159
|
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
|
|
157
160
|
```
|
|
158
161
|
|
|
@@ -160,6 +163,31 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
160
163
|
- When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
|
|
161
164
|
- Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
|
|
162
165
|
|
|
166
|
+
### Computed fields: putting what the arguments do not carry on the card
|
|
167
|
+
|
|
168
|
+
`diff.before` / `diff.after` / `diff.path` normally name a **parameter** (the host reads `args[name]`). Like a field's `value`, any of them may instead be a **function**: the host calls it with the pending `exec` before the card opens and uses what it returns as the text.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
ctx.hitl.protect('update_index', {
|
|
172
|
+
title: 'Update the knowledge-tree index',
|
|
173
|
+
fields: [
|
|
174
|
+
{ param: 'content', title: 'New index text', render: 'markdown' },
|
|
175
|
+
{ param: 'index', title: 'What changes in the index file', render: 'diff', editable: false,
|
|
176
|
+
// `before` reads the old index from disk, `after` derives the new file text —
|
|
177
|
+
// neither of them is one of the call's parameters
|
|
178
|
+
diff: { path: 'index.md',
|
|
179
|
+
before: async exec => readIndexFile(exec),
|
|
180
|
+
after: exec => `# Knowledge tree index\n\n${exec.arguments.content}` } },
|
|
181
|
+
],
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- A string `diff.path` that is **not** a parameter name is used as a literal path: the file a call is about to change is often not one of its arguments.
|
|
186
|
+
- Resolvers may be async; one field's resolution is bounded by `resolveTimeoutMs` (5s by default).
|
|
187
|
+
- **A failed resolution never blocks the decision**: a throw or a timeout turns *that field* into one `⚠️ …could not be resolved…` line, the card still opens, and the human still decides. If the resolution machinery itself breaks, the proposal degrades to the raw arguments instead of denying the call.
|
|
188
|
+
- The wire format and the browser half are unchanged: both sides of a diff always travelled in the frame; the panel just renders them.
|
|
189
|
+
- Each side of a diff is cut past `maxDiffLines` (2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.
|
|
190
|
+
|
|
163
191
|
### The three endings of a countdown
|
|
164
192
|
|
|
165
193
|
`countdown.action` decides what the host does when the countdown reaches zero:
|
|
@@ -274,12 +302,36 @@ The panel follows the **application's current language** — not the mounting pl
|
|
|
274
302
|
- **With no browser connected the default is fail-closed**: a tool mounted behind HITL is rejected and the model sees `Error: HITL: no browser is connected to decide, so tool "x" did not run (fail-closed).` Set `whenUnavailable` to `wait` if it should wait forever in unattended runs instead.
|
|
275
303
|
- In a composition with no Web service at all (headless/TUI) the plugin **stays active and keeps gating** — "no UI, therefore no gate" is not this plugin's semantics.
|
|
276
304
|
|
|
305
|
+
### 7.1 The security boundary: what HITL stops and what it cannot
|
|
306
|
+
|
|
307
|
+
- **It stops a call from *starting*; it does not stop the world.** If a tool has already produced a side effect inside its own
|
|
308
|
+
execution layer (a concurrent race, an asynchronous write, a request already sent), a rejection prevents "next", never "already" —
|
|
309
|
+
**HITL is neither a transaction nor a sandbox**.
|
|
310
|
+
- So how safe you actually are depends on two things: **① how dangerous the operations your matcher covers are; ② whether the tool
|
|
311
|
+
puts its irreversible step last.** Mounting `bash` / `write` / `edit` — tools that are dangerous the moment they start — is the
|
|
312
|
+
right use. If a tool only reveals its risk halfway through, HITL can only help you decide whether to begin.
|
|
313
|
+
- **A human's approval is not a privilege escalation**: the sandbox, guards, hooks and approval still run afterwards (§8); and the
|
|
314
|
+
reverse holds too — another pre-execution policy can refuse *before* this plugin is even asked. That was measured once:
|
|
315
|
+
`fs-observation-policy` stopped a call before the human pressed 【Approve】, so no HITL panel ever appeared (§8).
|
|
316
|
+
- **`whenUnavailable: wait` is fail-open**: with no browser connected the call simply waits, and without a `countdown` it waits until
|
|
317
|
+
its Session ends. Use the fail-closed default (`reject`) when an unattended run must not stall, or pair `wait` with a `countdown`
|
|
318
|
+
(the host warns at mount time when you do not).
|
|
319
|
+
- **The local trust boundary**: the decision channel and `/status` answer loopback addresses only. Any process on this machine can
|
|
320
|
+
read `/status`, which carries the **mount list** and, per pending decision, the **tool name / Session id / remaining milliseconds /
|
|
321
|
+
hold state** — **no tool arguments and no token**. To size that hole precisely: a local process can already read the session logs
|
|
322
|
+
under `~/.dsh/sessions/**`, which do contain every tool argument, so `/status` exposes a strictly smaller subset of what is already
|
|
323
|
+
readable locally. The hole is deliberate: without it, "what is HITL holding right now?" could only be answered by opening the page.
|
|
324
|
+
|
|
277
325
|
---
|
|
278
326
|
|
|
279
327
|
## 8. Relationship to other pre-execution policies
|
|
280
328
|
|
|
281
329
|
- This plugin listens on `tools/pre-execute` with `prepend: true`, i.e. **ask the human first**; once the human agrees, the sandbox, guards, hooks, approval, and every other policy still run afterwards. **A human's approval is not a privilege escalation.**
|
|
282
330
|
- So "the human approved but the sandbox or a guard still refused" is by design, not a bug.
|
|
331
|
+
- **The reverse holds too**: another pre-execution policy can refuse **before** this plugin is ever asked, in which case no HITL panel
|
|
332
|
+
appears at all — so "it ran (or failed) without asking me" is a question for the other policies first. Measured once:
|
|
333
|
+
`fs-observation-policy` demands a re-read before overwriting a deleted file, and it stopped a call both *after* the human pressed
|
|
334
|
+
【Approve】 (the panel had appeared as usual) and, in another round, *before* this plugin was reached (no panel at all).
|
|
283
335
|
- This plugin only asks before execution: it never rewrites a tool's arguments and never rewrites a tool's result.
|
|
284
336
|
|
|
285
337
|
---
|
|
@@ -287,6 +339,7 @@ The panel follows the **application's current language** — not the mounting pl
|
|
|
287
339
|
## 9. Known limitations
|
|
288
340
|
|
|
289
341
|
- **Tool arguments cannot be rewritten** (see §5), so 【Modify】 defaults to "refuse and hand the edit back".
|
|
342
|
+
- **HITL is not a side-effect boundary** (see §7.1): it stops a call from starting and cannot roll back a side effect that already happened; how much it protects you depends on the matcher and on the tool's own design.
|
|
290
343
|
- **Mount copy is not localized**: `title` / `labels` / field titles are strings the mounting plugin supplies; this plugin does not translate them and does not accept a `{zh, en}` map (see §6.3).
|
|
291
344
|
- **No session audit event**: DSH does not let a plugin append new event types, so the decision trail lives only in the tool result (and, under `allow-and-inform`, in one user message).
|
|
292
345
|
- **Only the Web UI has a panel**: under ACP/TUI/headless the mount's `whenUnavailable` decides (reject by default).
|
|
@@ -359,28 +412,30 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
|
|
|
359
412
|
|
|
360
413
|
```text
|
|
361
414
|
dsh-hitl/
|
|
362
|
-
├── index.js
|
|
415
|
+
├── index.js 666 host half: service + gate + countdown/hold + SSE & decision routes + token injection
|
|
363
416
|
├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
|
|
364
417
|
├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
|
|
365
|
-
│ ├── protocol.js
|
|
366
|
-
│ ├── resolve.js
|
|
367
|
-
│ ├── fields.js
|
|
418
|
+
│ ├── protocol.js 195 frame and uplink validation, limits, error codes (the single source of the protocol)
|
|
419
|
+
│ ├── resolve.js 363 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
|
|
420
|
+
│ ├── fields.js 449 default proposal derivation, diff pairing, computed-field resolution, decision → model-visible text
|
|
368
421
|
│ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
|
|
369
422
|
├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
|
|
370
423
|
│ ├── en.json 6
|
|
371
424
|
│ └── zh.json 6
|
|
372
|
-
├── docs/
|
|
373
|
-
├── tests/ zero-dependency Node tests,
|
|
425
|
+
├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
|
|
426
|
+
├── tests/ zero-dependency Node tests, 167 cases in total
|
|
374
427
|
│ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
|
|
375
|
-
│ ├── fields.test.js
|
|
428
|
+
│ ├── fields.test.js 387 (37) proposal derivation, computed fields and decision text
|
|
376
429
|
│ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
|
|
377
|
-
│ ├── resolve.test.js
|
|
430
|
+
│ ├── resolve.test.js 224 (22) matchers and option normalization
|
|
378
431
|
│ ├── protocol.test.js 117 (16) frame and uplink validation
|
|
379
|
-
│ ├── docs.test.js
|
|
380
|
-
│ └── manifest.test.js
|
|
381
|
-
├──
|
|
432
|
+
│ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
|
|
433
|
+
│ └── manifest.test.js 77 (4) package metadata and the locale resource shape
|
|
434
|
+
├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
|
|
435
|
+
├── package.json 68 manifest: exports / dsh.bundle.patch / dsh.client / icon
|
|
382
436
|
├── cordis.patch.yml 17 the bundle's configuration layer (inserts the row with id `hitl`)
|
|
383
437
|
├── icon.svg 6 the plugin list icon
|
|
438
|
+
├── LICENSE MIT
|
|
384
439
|
├── README.md this document
|
|
385
440
|
└── README.zh.md the Chinese twin of this document
|
|
386
441
|
```
|
|
@@ -392,7 +447,7 @@ dsh-hitl/
|
|
|
392
447
|
| `index.js`, `lib/*.js` | host half (Node) | **restart dsh**. Node's ESM module cache is not invalidated by "disable → enable on that row" |
|
|
393
448
|
| `client.js` | browser half | **client hot-reload** picks it up (the host stats each row's client artefact every 500ms and broadcasts over `/plugins/events`); no page refresh needed |
|
|
394
449
|
| `locale/*.json` | plugin-list metadata | when the plugin manager re-reads metadata (opening the plugins page / restarting dsh) |
|
|
395
|
-
| `docs/*.png` |
|
|
450
|
+
| `docs/*.png` | screenshot sources | kept in the repository only; the published package excludes them (see the appendix note) |
|
|
396
451
|
| `package.json`, `cordis.patch.yml` | manifest and config layer | a restart or a reload of that row; an installed package is not swapped in place |
|
|
397
452
|
| `tests/*.js` | development | never runs in production; executed by `npm test` / `node --test` |
|
|
398
453
|
|
|
@@ -408,7 +463,7 @@ dsh-hitl/
|
|
|
408
463
|
|
|
409
464
|
```sh
|
|
410
465
|
node --check index.js client.js lib/*.js # syntax
|
|
411
|
-
npm test #
|
|
466
|
+
npm test # 167 zero-dependency cases: pure functions, state machines, the channel
|
|
412
467
|
npm run check # both of the above (syntax + the whole suite)
|
|
413
468
|
```
|
|
414
469
|
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-hitl · 给任意工具挂上人工决策(Human-in-the-loop)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-hitl) [](LICENSE) [](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
4
|
+
|
|
3
5
|
[English](README.md) | 中文
|
|
4
6
|
|
|
5
7
|
> **让 Agent 在动手之前,先问你一句。**
|
|
@@ -8,7 +10,7 @@
|
|
|
8
10
|
`dsh-hitl` 是 DeepSeek Harness (DSH) 的可安装插件(bundle):**零依赖、零构建**——`index.js`(宿主半)+ `client.js`(浏览器半)+ `lib/`
|
|
9
11
|
全是可直接加载的纯 JS,装上刷新一次页面就能用。
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
**[访问网站](https://yunpengdon.github.io/dsh-hitl-landing/)**
|
|
12
14
|
|
|
13
15
|
## 主要功能
|
|
14
16
|
|
|
@@ -19,7 +21,6 @@
|
|
|
19
21
|
- **带反馈文本的拒绝**:【拒绝】可以带一个**反馈栏**,在反馈栏中写下的意见会随拒绝一起交给 Agent——它知道"不要做",也知道"为什么不要做";
|
|
20
22
|
- **支持倒计时配置**:可配置**倒计时**与三种结局(**自动同意 / 自动拒绝 / 把超时事件反馈给 Agent**);
|
|
21
23
|
|
|
22
|
-

|
|
23
24
|
|
|
24
25
|
## 为DSH定制的适配
|
|
25
26
|
|
|
@@ -28,12 +29,12 @@
|
|
|
28
29
|
「来自子代理会话 xxx」+【前往该会话】。
|
|
29
30
|
(跨进程后端不进这道门禁,见 §6.1。)
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
[在 landing page 上看这张卡 →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
32
33
|
|
|
33
34
|
**不在对话界面上时,支持弹出迷你批准请求** 切到插件页、打开设置、甚至没选会话时,页面顶部会浮出一条提醒事项,减少用户不在对话页时HITL对流程的阻塞。
|
|
34
35
|
(条数、工具名、来源会话、剩余时间 +【同意】【拒绝】【前往该会话】)。
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
[在 landing page 上看这条浮层 →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
37
38
|
|
|
38
39
|
---
|
|
39
40
|
|
|
@@ -155,9 +156,10 @@ export function apply(ctx) {
|
|
|
155
156
|
| `reject.feedbackPrompt` | string | 内置文案 | 反馈栏 placeholder |
|
|
156
157
|
| `reject.requireFeedback` | boolean | `false` | 反馈必填(为空时拒绝按钮会提示) |
|
|
157
158
|
| `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | 用户改了文本后点【修改】的语义,见 §5 |
|
|
158
|
-
| `whenUnavailable` | `reject` \| `wait` | `reject` |
|
|
159
|
+
| `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
|
|
159
160
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
|
|
160
161
|
| `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
|
|
162
|
+
| `resolveTimeoutMs` | number | `5000` | 单个**计算字段**的解析上限;超时或抛错只让那个字段显示失败,不阻断这次调用 |
|
|
161
163
|
|
|
162
164
|
### FieldSpec
|
|
163
165
|
|
|
@@ -166,6 +168,7 @@ export function apply(ctx) {
|
|
|
166
168
|
render: 'markdown', // markdown | text | diff | json | hidden
|
|
167
169
|
editable: true, // 覆盖默认可编辑性
|
|
168
170
|
labels: ['不可撤销'], // 字段级标签
|
|
171
|
+
value: '固定文案', // 该字段的文本:字面量,或 (exec) => string | Promise<string>
|
|
169
172
|
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // render: 'diff' 时用
|
|
170
173
|
```
|
|
171
174
|
|
|
@@ -173,6 +176,30 @@ export function apply(ctx) {
|
|
|
173
176
|
- `fields` 给定时**只显示**列出的字段(未列出的参数不会出现在面板里)。
|
|
174
177
|
- 配置行里可以简写成字符串:`fields: ['command', { param: 'cwd', title: '工作目录' }]`。
|
|
175
178
|
|
|
179
|
+
### 计算字段:把参数之外的东西放进卡里
|
|
180
|
+
|
|
181
|
+
`diff.before` / `diff.after` / `diff.path` 平时填的是**参数名**(宿主取 `args[名字]`)。它们与字段的 `value` 一样,也可以填**函数**:宿主在弹卡前用这次调用的 `exec` 调它,把返回值当作文本。
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
ctx.hitl.protect('update_index', {
|
|
185
|
+
title: '更新知识树索引',
|
|
186
|
+
fields: [
|
|
187
|
+
{ param: 'content', title: '新索引正文', render: 'markdown' },
|
|
188
|
+
{ param: 'index', title: '索引文件的变化', render: 'diff', editable: false,
|
|
189
|
+
// before 读磁盘上的旧索引,after 由新正文推出来——两者都不在参数里
|
|
190
|
+
diff: { path: 'index.md',
|
|
191
|
+
before: async exec => readIndexFile(exec),
|
|
192
|
+
after: exec => `# 知识树索引\n\n${exec.arguments.content}` } },
|
|
193
|
+
],
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
- 字符串形式的 `diff.path` 若**不是**某个参数名,就当作字面路径使用:被改的文件常常不是这次调用的参数。
|
|
198
|
+
- 函数可以是 async;每个字段的解析上限是 `resolveTimeoutMs`(默认 5s)。
|
|
199
|
+
- **解析失败不阻断**:函数抛错或超时,只会把**那个字段**变成一行 `⚠️ 无法解析该字段:…`,卡照常弹出、人照常决策。整个解析过程坏掉时,提案退回到"参数原样",而不是拒绝调用。
|
|
200
|
+
- wire 格式与浏览器半都没有变化:diff 的两侧本来就在帧里,前端只是照常渲染。
|
|
201
|
+
- diff 的每一侧超过 `maxDiffLines`(2000 行)会被截断并标注,避免整文件 diff 变成无上限的一帧。
|
|
202
|
+
|
|
176
203
|
### 倒计时的三种结局
|
|
177
204
|
|
|
178
205
|
`countdown.action` 决定倒计时归零后宿主怎么判:
|
|
@@ -340,6 +367,21 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
340
367
|
想让它在无人值守时一直等,把 `whenUnavailable` 设为 `wait`。
|
|
341
368
|
- 插件在没有任何 Web 服务的组合里(headless/TUI)**仍然激活并继续拦**——"没有 UI 就不拦"不是本插件的语义。
|
|
342
369
|
|
|
370
|
+
### 7.1 安全边界:HITL 拦得住什么、拦不住什么
|
|
371
|
+
|
|
372
|
+
- **它拦的是"这一次调用在真正执行之前",不是世界状态。** 如果工具在自己的执行层已经产生了副作用(并发竞态、异步落盘、
|
|
373
|
+
已经发出去的外部请求),拒绝只能阻止"接下来",回滚不了"已经"——**HITL 不是事务,也不是沙箱**。
|
|
374
|
+
- 所以实际安全程度取决于两件事:**① 你的 matcher 覆盖了多危险的操作;② 工具是否把不可逆动作放在最后一刻。**
|
|
375
|
+
把 `bash` / `write` / `edit` 这类"入口即危险"的工具挂上是对的;若某个工具的风险要到执行中途才出现,HITL 只能替你决定"要不要开始"。
|
|
376
|
+
- **用户的同意 ≠ 越权**:同意之后沙箱、guard、hooks、审批照常随后生效(§8);反过来,别的执行前策略也可能**先于**本插件拒绝——
|
|
377
|
+
实测过一次:`fs-observation-policy` 在用户点【同意】之前就把调用挡了,HITL 连面板都没弹(见 §8)。
|
|
378
|
+
- **`whenUnavailable: wait` 是 fail-open**:没有任何浏览器连接时会一直等,没配 `countdown` 就是等到会话结束。
|
|
379
|
+
想让"无人值守也不卡死",用默认的 `reject`,或者 `wait` + `countdown`(挂载时宿主会对这种组合给出警告)。
|
|
380
|
+
- **本机信任边界**:决策通道与 `/status` 都只认回环地址。同机任意进程可以读 `/status`,它只含**挂载列表**与**待决策的
|
|
381
|
+
工具名 / 会话 id / 剩余毫秒 / 是否暂停**——**没有工具参数,也没有令牌**。要把这个口子量准:同机进程本来就能读
|
|
382
|
+
`~/.dsh/sessions/**` 的会话日志,而那里有完整的工具参数,所以 `/status` 的暴露面是它严格更小的子集。
|
|
383
|
+
这个口子是有意保留的:没有它,在终端里回答"HITL 现在拦着什么"就只能靠翻页面。
|
|
384
|
+
|
|
343
385
|
---
|
|
344
386
|
|
|
345
387
|
## 8. 与其他执行前策略的关系
|
|
@@ -347,6 +389,9 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
347
389
|
- 本插件用 `tools/pre-execute` 且 `prepend: true`,也就是**先问人**;用户同意后,
|
|
348
390
|
沙箱、guard、hooks、审批等其它策略照常随后生效。**用户的同意不等于越权**。
|
|
349
391
|
- 因此可能出现"用户同意了,但工具仍被沙箱/守卫拒绝"——这是设计使然,不是 bug。
|
|
392
|
+
- **反过来也成立**:别的执行前策略可能在**本插件之前**就拒绝,此时 HITL 面板根本不会出现——所以"没弹面板就直接执行了/就失败了"
|
|
393
|
+
两种现象都要先看别的策略。实测过一次:`fs-observation-policy` 要求"重写已删除文件前先重读",
|
|
394
|
+
它在用户点【同意】**之后**把调用挡了下来(面板照常弹过),也在另一轮里**先于**本插件直接拒绝(面板没弹)。
|
|
350
395
|
- 本插件只做"执行前问人",不改变工具参数、不改变工具结果。
|
|
351
396
|
|
|
352
397
|
---
|
|
@@ -354,6 +399,7 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
354
399
|
## 9. 已知限制
|
|
355
400
|
|
|
356
401
|
- **不能改写工具参数**(见 §5),所以【修改】默认走"拒绝并交回修改内容"。
|
|
402
|
+
- **HITL 不是副作用边界**(见 §7.1):它拦的是"调用开始之前",回滚不了已经发生的副作用;安全性取决于 matcher 覆盖面与工具自身的设计。
|
|
357
403
|
- **挂载文案不本地化**:`title` / `labels` / 字段标题都是挂载方给的字符串,本插件不翻译,也不接受 `{zh, en}` 映射(见 §6.3)。
|
|
358
404
|
- **不写会话审计事件**:DSH 不允许插件追加新的事件类型,决策痕迹只存在于工具结果(和 `allow-and-inform` 的那条 user 消息)里。
|
|
359
405
|
- **只有 Web 界面有面板**:ACP/TUI/headless 下按 `whenUnavailable` 处理(默认拒绝)。
|
|
@@ -428,28 +474,30 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
428
474
|
|
|
429
475
|
```text
|
|
430
476
|
dsh-hitl/
|
|
431
|
-
├── index.js
|
|
477
|
+
├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
|
|
432
478
|
├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
|
|
433
479
|
├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
|
|
434
|
-
│ ├── protocol.js
|
|
435
|
-
│ ├── resolve.js
|
|
436
|
-
│ ├── fields.js
|
|
480
|
+
│ ├── protocol.js 195 帧与上行校验、上限、错误码(协议规范的唯一出处)
|
|
481
|
+
│ ├── resolve.js 363 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
|
|
482
|
+
│ ├── fields.js 449 默认提案推导、diff 配对、计算字段解析、决策 → 模型可见文本
|
|
437
483
|
│ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
|
|
438
484
|
├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
|
|
439
485
|
│ ├── en.json 6
|
|
440
486
|
│ └── zh.json 6
|
|
441
|
-
├── docs/
|
|
442
|
-
├── tests/ 零依赖 Node 测试,共
|
|
487
|
+
├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
|
|
488
|
+
├── tests/ 零依赖 Node 测试,共 167 个用例
|
|
443
489
|
│ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
|
|
444
|
-
│ ├── fields.test.js
|
|
490
|
+
│ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
|
|
445
491
|
│ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
|
|
446
|
-
│ ├── resolve.test.js
|
|
492
|
+
│ ├── resolve.test.js 224 (22)matcher 与选项归一化
|
|
447
493
|
│ ├── protocol.test.js 117 (16)帧与上行校验
|
|
448
|
-
│ ├── docs.test.js
|
|
449
|
-
│ └── manifest.test.js
|
|
450
|
-
├──
|
|
494
|
+
│ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
|
|
495
|
+
│ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
|
|
496
|
+
├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
|
|
497
|
+
├── package.json 68 清单:exports / dsh.bundle.patch / dsh.client / icon
|
|
451
498
|
├── cordis.patch.yml 17 bundle 的配置层(插入 id 为 hitl 的那一行)
|
|
452
499
|
├── icon.svg 6 插件列表图标
|
|
500
|
+
├── LICENSE MIT
|
|
453
501
|
├── README.md 本文档(英文版)
|
|
454
502
|
└── README.zh.md 本文档的中文版
|
|
455
503
|
```
|
|
@@ -461,7 +509,7 @@ dsh-hitl/
|
|
|
461
509
|
| `index.js`、`lib/*.js` | 宿主半(Node) | 必须**重启 dsh**。Node 的 ESM 模块缓存不会因为"禁用→启用这一行"而失效 |
|
|
462
510
|
| `client.js` | 浏览器半 | **客户端热重载**自动拾取(宿主每 500ms stat 一次各行的 client 产物并广播 `/plugins/events`),不用刷新页面 |
|
|
463
511
|
| `locale/*.json` | 插件列表元数据 | 插件管理器重读元数据时生效(打开插件页 / 重启 dsh) |
|
|
464
|
-
| `docs/*.png` |
|
|
512
|
+
| `docs/*.png` | 截图源文件 | 只留在仓库里;发布的包里不再包含(见附录说明) |
|
|
465
513
|
| `package.json`、`cordis.patch.yml` | 清单与配置层 | 需要重启或重新加载该行;已安装包不会被就地替换 |
|
|
466
514
|
| `tests/*.js` | 开发期 | 不参与运行,只由 `npm test` / `node --test` 执行 |
|
|
467
515
|
|
|
@@ -483,7 +531,7 @@ dsh-hitl/
|
|
|
483
531
|
|
|
484
532
|
```sh
|
|
485
533
|
node --check index.js client.js lib/*.js # 语法
|
|
486
|
-
npm test #
|
|
534
|
+
npm test # 167 个纯函数/状态机/通道用例,零依赖
|
|
487
535
|
npm run check # 上面两步合起来(语法 + 全部用例)
|
|
488
536
|
```
|
|
489
537
|
|
package/index.js
CHANGED
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
DEFAULT_ENDPOINT, ERROR_CODES, GLOBAL_KEY, LIMITS, OUTCOMES, errorBody, frameHoldAck,
|
|
22
22
|
framePing, frameRequest, frameSettled, frameSnapshot, okBody, parseUplink, sseChunk,
|
|
23
23
|
} from './lib/protocol.js'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
buildRequest, capReason, describeDecision, hasComputed, isRevision, resolveFields, revisionContext,
|
|
26
|
+
} from './lib/fields.js'
|
|
25
27
|
import { DEFAULT_HOLD_GRACE_MS, createPendingRegistry } from './lib/pending.js'
|
|
26
28
|
import { matchMount, mountApplies, normalizeMount, normalizeProtectList } from './lib/resolve.js'
|
|
27
29
|
|
|
@@ -107,7 +109,14 @@ export function apply(ctx, config = {}) {
|
|
|
107
109
|
const clients = new Set()
|
|
108
110
|
const resolvers = new Map()
|
|
109
111
|
const cleanups = new Map()
|
|
110
|
-
|
|
112
|
+
// Revision context for `allow-and-inform`, keyed by the execution object the
|
|
113
|
+
// pipeline carries from `tools/pre-execute` through `tools/post-execute`.
|
|
114
|
+
// A WeakMap, not a callId-keyed Map: if a call is allowed and then never
|
|
115
|
+
// reaches post-execute (an aborted turn, a process torn down mid-execution),
|
|
116
|
+
// a Map entry would outlive the call for the rest of the process. Keyed by
|
|
117
|
+
// the execution itself, the entry dies with the call that owns it, so there
|
|
118
|
+
// is nothing to leak and nothing to remember to clean up.
|
|
119
|
+
const revisions = new WeakMap()
|
|
111
120
|
|
|
112
121
|
const registry = createPendingRegistry({
|
|
113
122
|
holdGraceMs: positiveInteger(options.holdGraceMs, DEFAULT_HOLD_GRACE_MS),
|
|
@@ -227,6 +236,8 @@ export function apply(ctx, config = {}) {
|
|
|
227
236
|
rejectFeedback: mount.options.reject.feedback,
|
|
228
237
|
modify: mount.options.modify.mode,
|
|
229
238
|
whenUnavailable: mount.options.whenUnavailable,
|
|
239
|
+
/** Whether this mount's proposal needs the host to compute a field. */
|
|
240
|
+
computed: hasComputed(mount.options),
|
|
230
241
|
})),
|
|
231
242
|
/** Every decision still waiting for a human, oldest first. */
|
|
232
243
|
pending: () => registry.list().map(request => ({
|
|
@@ -293,7 +304,9 @@ export function apply(ctx, config = {}) {
|
|
|
293
304
|
if (settlement.decision.kind === 'cancel') return { kind: 'cancel' }
|
|
294
305
|
const revising = isRevision(settlement.decision)
|
|
295
306
|
if (revising && mount.options.modify.mode === 'allow-and-inform' && settlement.source === OUTCOMES.user) {
|
|
296
|
-
|
|
307
|
+
// The call is about to run with its original arguments; the edit rides
|
|
308
|
+
// along as context on the result instead (see the post-execute listener).
|
|
309
|
+
revisions.set(exec, { request, decision: settlement.decision })
|
|
297
310
|
return { kind: 'allow' }
|
|
298
311
|
}
|
|
299
312
|
const info = settlement.source === OUTCOMES.timeout
|
|
@@ -306,6 +319,23 @@ export function apply(ctx, config = {}) {
|
|
|
306
319
|
}
|
|
307
320
|
}
|
|
308
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Collect a mount's computed fields for one call.
|
|
324
|
+
*
|
|
325
|
+
* If the resolution itself breaks, the proposal degrades to the call's own
|
|
326
|
+
* arguments: a panel of raw parameters is still a decision the human can make,
|
|
327
|
+
* while a denied call is not. A resolver that fails on its own degrades inside
|
|
328
|
+
* `resolveFields` instead, into one field that says so.
|
|
329
|
+
*/
|
|
330
|
+
async function resolutionFor(exec, mount) {
|
|
331
|
+
try {
|
|
332
|
+
return await resolveFields(exec, mount, { timeoutMs: mount.options.resolveTimeoutMs, warn })
|
|
333
|
+
} catch (error) {
|
|
334
|
+
warn(`the computed fields of tool ${JSON.stringify(exec.name)} could not be resolved: ${String(error)}`)
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
309
339
|
async function gate(exec, next) {
|
|
310
340
|
let mount
|
|
311
341
|
try {
|
|
@@ -326,7 +356,13 @@ export function apply(ctx, config = {}) {
|
|
|
326
356
|
}
|
|
327
357
|
const id = `hitl:${String(exec.callId ?? randomUUID())}`
|
|
328
358
|
try {
|
|
329
|
-
const request = buildRequest({
|
|
359
|
+
const request = buildRequest({
|
|
360
|
+
execution: exec,
|
|
361
|
+
mount,
|
|
362
|
+
sessionId,
|
|
363
|
+
id,
|
|
364
|
+
resolution: await resolutionFor(exec, mount),
|
|
365
|
+
})
|
|
330
366
|
const settlement = await waitForDecision(id, request, mount, exec)
|
|
331
367
|
return toPreToolDecision(exec, request, mount, settlement)
|
|
332
368
|
} catch (error) {
|
|
@@ -342,17 +378,17 @@ export function apply(ctx, config = {}) {
|
|
|
342
378
|
|
|
343
379
|
/**
|
|
344
380
|
* Hand the user's revision to the model next to the result it approved, in
|
|
345
|
-
* the `allow-and-inform` modify mode.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
381
|
+
* the `allow-and-inform` modify mode. Lookup is by the execution object, so a
|
|
382
|
+
* call that never reaches this stage costs nothing but its own lifetime; the
|
|
383
|
+
* delete keeps a second post-execute for the same execution from attaching
|
|
384
|
+
* the same context twice.
|
|
348
385
|
*/
|
|
349
386
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
350
387
|
const decision = await next()
|
|
351
|
-
|
|
352
|
-
const key = String(exec.callId)
|
|
353
|
-
const revision = revisions.get(key)
|
|
388
|
+
const revision = revisions.get(exec)
|
|
354
389
|
if (revision === undefined) return decision
|
|
355
|
-
revisions.delete(
|
|
390
|
+
revisions.delete(exec)
|
|
391
|
+
const label = exec.callId === undefined ? String(exec.name) : String(exec.callId)
|
|
356
392
|
try {
|
|
357
393
|
const message = {
|
|
358
394
|
id: randomUUID(),
|
|
@@ -362,7 +398,7 @@ export function apply(ctx, config = {}) {
|
|
|
362
398
|
}
|
|
363
399
|
return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), message] }
|
|
364
400
|
} catch (error) {
|
|
365
|
-
warn(`the revision context for ${
|
|
401
|
+
warn(`the revision context for ${label} was dropped: ${String(error)}`)
|
|
366
402
|
return decision
|
|
367
403
|
}
|
|
368
404
|
})
|
|
@@ -622,7 +658,8 @@ export function apply(ctx, config = {}) {
|
|
|
622
658
|
}
|
|
623
659
|
}
|
|
624
660
|
mounts.length = 0
|
|
625
|
-
revisions
|
|
661
|
+
// `revisions` is a WeakMap: shutdown has nothing to clear, and a call that
|
|
662
|
+
// never came back takes its own entry with it.
|
|
626
663
|
resolvers.clear()
|
|
627
664
|
cleanups.clear()
|
|
628
665
|
}, 'dsh-hitl: shutdown')
|