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 CHANGED
@@ -1,4 +1,6 @@
1
- # dsh-hitl · Human-in-the-loop for any tool
1
+ # dsh-hitl · Human-in-the-loop for DeepSeek Harness
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-hitl)](https://www.npmjs.com/package/dsh-hitl) [![license](https://img.shields.io/npm/l/dsh-hitl)](LICENSE) [![Website](https://img.shields.io/badge/Website-4c566a)](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
- ![A complete decision card: title, pending proposal, approve/modify/reject buttons, and a countdown](docs/01-panel-en.png)
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
- ![The three proposal renderers in one card: a split diff, a text input, and markdown preview with edit](docs/02-fields-en.png)
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
- ![A card in the main conversation, labelled as coming from a subagent Session, with a jump button](docs/03-subagent-en.png)
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
- ![The fallback notice floating above the settings modal](docs/04-overlay-en.png)
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 (`reject` = fail-closed) |
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 629 host half: service + gate + countdown/hold + SSE & decision routes + token injection
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 182 frame and uplink validation, limits, error codes (the single source of the protocol)
366
- │ ├── resolve.js 302 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
367
- │ ├── fields.js 244 default proposal derivation, diff pairing, decision → model-visible text
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/ the screenshots shown above, one set per language (-en / -cn)
373
- ├── tests/ zero-dependency Node tests, 151 cases in total
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 269 (26) proposal derivation and decision text
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 162 (18) matchers and option normalization
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 116 (5) the two READMEs, their links, and the numbers in this very table
380
- │ └── manifest.test.js 61 (3) package metadata and the locale resource shape
381
- ├── package.json 69 manifest: exports / dsh.bundle.patch / dsh.client / icon
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` | documentation | when the README is rendered |
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 # 151 zero-dependency cases: pure functions, state machines, the channel
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
+ [![npm version](https://img.shields.io/npm/v/dsh-hitl)](https://www.npmjs.com/package/dsh-hitl) [![license](https://img.shields.io/npm/l/dsh-hitl)](LICENSE) [![官网](https://img.shields.io/badge/%E5%AE%98%E7%BD%91-4c566a)](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
- ![一张完整的决策卡:标题 + 待决策提案 + 同意/修改/拒绝 + 倒计时](docs/01-panel-cn.png)
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
- ![提案区三种形态同框:并排 diff / 文本输入框 / markdown 的预览与编辑](docs/02-fields-cn.png)
23
24
 
24
25
  ## 为DSH定制的适配
25
26
 
@@ -28,12 +29,12 @@
28
29
  「来自子代理会话 xxx」+【前往该会话】。
29
30
  (跨进程后端不进这道门禁,见 §6.1。)
30
31
 
31
- ![主对话里的卡片:标着「来自子代理会话」+【前往该会话】](docs/03-subagent-cn.png)
32
+ [在 landing page 上看这张卡 →](https://yunpengdon.github.io/dsh-hitl-landing/)
32
33
 
33
34
  **不在对话界面上时,支持弹出迷你批准请求** 切到插件页、打开设置、甚至没选会话时,页面顶部会浮出一条提醒事项,减少用户不在对话页时HITL对流程的阻塞。
34
35
  (条数、工具名、来源会话、剩余时间 +【同意】【拒绝】【前往该会话】)。
35
36
 
36
- ![设置面板之上浮着的兜底提醒条](docs/04-overlay-cn.png)
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` | 没有任何浏览器连接时的行为(`reject` = fail-closed) |
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 629 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
477
+ ├── index.js 666 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
432
478
  ├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
433
479
  ├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
434
- │ ├── protocol.js 182 帧与上行校验、上限、错误码(协议规范的唯一出处)
435
- │ ├── resolve.js 302 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
436
- │ ├── fields.js 244 默认提案推导、diff 配对、决策 → 模型可见文本
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/ 上文那些截图,中英各一套(-cn / -en)
442
- ├── tests/ 零依赖 Node 测试,共 151 个用例
487
+ ├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
488
+ ├── tests/ 零依赖 Node 测试,共 167 个用例
443
489
  │ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
444
- │ ├── fields.test.js 269 (26)提案推导与决策文本
490
+ │ ├── fields.test.js 387 (37)提案推导、计算字段与决策文本
445
491
  │ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
446
- │ ├── resolve.test.js 162 (18)matcher 与选项归一化
492
+ │ ├── resolve.test.js 224 (22)matcher 与选项归一化
447
493
  │ ├── protocol.test.js 117 (16)帧与上行校验
448
- │ ├── docs.test.js 116 (5)两份 README、互链,以及上面这张表里的每个数字
449
- │ └── manifest.test.js 61 (3)包元数据与 locale 资源形状
450
- ├── package.json 69 清单:exports / dsh.bundle.patch / dsh.client / icon
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` | 文档 | README 渲染时 |
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 # 151 个纯函数/状态机/通道用例,零依赖
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 { buildRequest, capReason, describeDecision, isRevision, revisionContext } from './lib/fields.js'
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
- const revisions = new Map()
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
- if (exec.callId !== undefined) revisions.set(String(exec.callId), { request, decision: settlement.decision })
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({ execution: exec, mount, sessionId, id })
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. The message is built to the shape
346
- * `createUserMessage` produces, because a plain bundle cannot import that
347
- * factory; `source.kind` is the standard `user` one.
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
- if (revisions.size === 0 || exec.callId === undefined) return decision
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(key)
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 ${key} was dropped: ${String(error)}`)
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.clear()
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')