dsh-hitl 0.1.0 → 0.1.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/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-hitl · Human-in-the-loop for any tool
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) [![Website](https://img.shields.io/badge/Website-4c566a)](https://yunpengdon.github.io/dsh-hitl-landing/)
4
+
3
5
  English | [中文](README.zh.md)
4
6
 
5
7
  > **Let the agent ask you before it acts.**
@@ -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,24 +20,26 @@ 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
 
36
37
  ## 1. Install
37
38
 
38
39
  ```sh
40
+ # From npm (the canonical install)
41
+ dsh plugin --profile <your profile> add dsh-hitl
42
+
39
43
  # Straight from GitHub (nothing to build)
40
44
  dsh plugin --profile <your profile> add github:YunpengDon/dsh-hitl
41
45
 
@@ -66,7 +70,7 @@ Once installed:
66
70
  config:
67
71
  protect:
68
72
  - tool: bash # tool name; patterns like 'mcp__*' work too
69
- title: 执行命令前确认 # panel title (bold, wraps)
73
+ title: Confirm before running # panel title (bold, wraps)
70
74
  countdown: { seconds: 30, action: reject }
71
75
  reject: { feedback: true } # show the feedback box on reject
72
76
  ```
@@ -82,13 +86,13 @@ export const inject = ['hitl'] // wait for the hitl service before ap
82
86
  export function apply(ctx) {
83
87
  // Passing your own ctx as the third argument binds the mount to this plugin's lifetime
84
88
  ctx.hitl.protect('bash', {
85
- title: '这条命令要执行吗?',
89
+ title: 'Run this command?',
86
90
  countdown: { seconds: 30, action: 'reject' },
87
91
  reject: { feedback: true },
88
92
  }, ctx)
89
93
 
90
94
  ctx.hitl.protect(['write', 'edit'], { // arrays, globs, RegExp, and predicates all work
91
- title: '改动前确认',
95
+ title: 'Confirm before editing',
92
96
  diff: { path: 'file_path', before: 'old_string', after: 'new_string' }, // folded into a side-by-side diff
93
97
  countdown: null, // no countdown = wait forever (still cancellable)
94
98
  }, ctx)
@@ -139,23 +143,23 @@ Without `fields`, the proposal is **every parameter of this call**:
139
143
  | `reject.feedbackPrompt` | string | built-in copy | Feedback box placeholder |
140
144
  | `reject.requireFeedback` | boolean | `false` | Feedback is required (an empty box is refused with a hint) |
141
145
  | `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | What 【Modify】 means after an edit; see §5 |
142
- | `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 |
143
147
  | `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
144
148
  | `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
145
149
 
146
150
  ### FieldSpec
147
151
 
148
152
  ```js
149
- { param: 'content', title: '新内容', description: '会写入文件的内容',
153
+ { param: 'content', title: 'New content', description: 'Text that will be written to the file',
150
154
  render: 'markdown', // markdown | text | diff | json | hidden
151
155
  editable: true, // overrides the default editability
152
- labels: ['不可撤销'], // field-level labels
156
+ labels: ['irreversible'], // field-level labels
153
157
  diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
154
158
  ```
155
159
 
156
160
  - With no `render`, a field renders as a diff when one can be configured, and as a markdown input otherwise.
157
161
  - When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
158
- - Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: '工作目录' }]`.
162
+ - Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
159
163
 
160
164
  ### The three endings of a countdown
161
165
 
@@ -271,12 +275,36 @@ The panel follows the **application's current language** — not the mounting pl
271
275
  - **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.
272
276
  - 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.
273
277
 
278
+ ### 7.1 The security boundary: what HITL stops and what it cannot
279
+
280
+ - **It stops a call from *starting*; it does not stop the world.** If a tool has already produced a side effect inside its own
281
+ execution layer (a concurrent race, an asynchronous write, a request already sent), a rejection prevents "next", never "already" —
282
+ **HITL is neither a transaction nor a sandbox**.
283
+ - So how safe you actually are depends on two things: **① how dangerous the operations your matcher covers are; ② whether the tool
284
+ puts its irreversible step last.** Mounting `bash` / `write` / `edit` — tools that are dangerous the moment they start — is the
285
+ right use. If a tool only reveals its risk halfway through, HITL can only help you decide whether to begin.
286
+ - **A human's approval is not a privilege escalation**: the sandbox, guards, hooks and approval still run afterwards (§8); and the
287
+ reverse holds too — another pre-execution policy can refuse *before* this plugin is even asked. That was measured once:
288
+ `fs-observation-policy` stopped a call before the human pressed 【Approve】, so no HITL panel ever appeared (§8).
289
+ - **`whenUnavailable: wait` is fail-open**: with no browser connected the call simply waits, and without a `countdown` it waits until
290
+ its Session ends. Use the fail-closed default (`reject`) when an unattended run must not stall, or pair `wait` with a `countdown`
291
+ (the host warns at mount time when you do not).
292
+ - **The local trust boundary**: the decision channel and `/status` answer loopback addresses only. Any process on this machine can
293
+ read `/status`, which carries the **mount list** and, per pending decision, the **tool name / Session id / remaining milliseconds /
294
+ hold state** — **no tool arguments and no token**. To size that hole precisely: a local process can already read the session logs
295
+ under `~/.dsh/sessions/**`, which do contain every tool argument, so `/status` exposes a strictly smaller subset of what is already
296
+ readable locally. The hole is deliberate: without it, "what is HITL holding right now?" could only be answered by opening the page.
297
+
274
298
  ---
275
299
 
276
300
  ## 8. Relationship to other pre-execution policies
277
301
 
278
302
  - 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.**
279
303
  - So "the human approved but the sandbox or a guard still refused" is by design, not a bug.
304
+ - **The reverse holds too**: another pre-execution policy can refuse **before** this plugin is ever asked, in which case no HITL panel
305
+ appears at all — so "it ran (or failed) without asking me" is a question for the other policies first. Measured once:
306
+ `fs-observation-policy` demands a re-read before overwriting a deleted file, and it stopped a call both *after* the human pressed
307
+ 【Approve】 (the panel had appeared as usual) and, in another round, *before* this plugin was reached (no panel at all).
280
308
  - This plugin only asks before execution: it never rewrites a tool's arguments and never rewrites a tool's result.
281
309
 
282
310
  ---
@@ -284,6 +312,7 @@ The panel follows the **application's current language** — not the mounting pl
284
312
  ## 9. Known limitations
285
313
 
286
314
  - **Tool arguments cannot be rewritten** (see §5), so 【Modify】 defaults to "refuse and hand the edit back".
315
+ - **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.
287
316
  - **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).
288
317
  - **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).
289
318
  - **Only the Web UI has a panel**: under ACP/TUI/headless the mount's `whenUnavailable` decides (reject by default).
@@ -356,28 +385,30 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
356
385
 
357
386
  ```text
358
387
  dsh-hitl/
359
- ├── index.js 629 host half: service + gate + countdown/hold + SSE & decision routes + token injection
388
+ ├── index.js 639 host half: service + gate + countdown/hold + SSE & decision routes + token injection
360
389
  ├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
361
390
  ├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
362
391
  │ ├── protocol.js 182 frame and uplink validation, limits, error codes (the single source of the protocol)
363
- │ ├── resolve.js 302 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
392
+ │ ├── resolve.js 311 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
364
393
  │ ├── fields.js 244 default proposal derivation, diff pairing, decision → model-visible text
365
394
  │ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
366
395
  ├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
367
396
  │ ├── en.json 6
368
397
  │ └── zh.json 6
369
- ├── docs/ the screenshots shown above, one set per language (-en / -cn)
370
- ├── tests/ zero-dependency Node tests, 151 cases in total
398
+ ├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
399
+ ├── tests/ zero-dependency Node tests, 153 cases in total
371
400
  │ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
372
401
  │ ├── fields.test.js 269 (26) proposal derivation and decision text
373
402
  │ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
374
- │ ├── resolve.test.js 162 (18) matchers and option normalization
403
+ │ ├── resolve.test.js 181 (19) matchers and option normalization
375
404
  │ ├── protocol.test.js 117 (16) frame and uplink validation
376
- │ ├── docs.test.js 116 (5) the two READMEs, their links, and the numbers in this very table
377
- │ └── manifest.test.js 61 (3) package metadata and the locale resource shape
378
- ├── package.json 69 manifest: exports / dsh.bundle.patch / dsh.client / icon
405
+ │ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
406
+ │ └── manifest.test.js 77 (4) package metadata and the locale resource shape
407
+ ├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
408
+ ├── package.json 68 manifest: exports / dsh.bundle.patch / dsh.client / icon
379
409
  ├── cordis.patch.yml 17 the bundle's configuration layer (inserts the row with id `hitl`)
380
410
  ├── icon.svg 6 the plugin list icon
411
+ ├── LICENSE MIT
381
412
  ├── README.md this document
382
413
  └── README.zh.md the Chinese twin of this document
383
414
  ```
@@ -389,7 +420,7 @@ dsh-hitl/
389
420
  | `index.js`, `lib/*.js` | host half (Node) | **restart dsh**. Node's ESM module cache is not invalidated by "disable → enable on that row" |
390
421
  | `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 |
391
422
  | `locale/*.json` | plugin-list metadata | when the plugin manager re-reads metadata (opening the plugins page / restarting dsh) |
392
- | `docs/*.png` | documentation | when the README is rendered |
423
+ | `docs/*.png` | screenshot sources | kept in the repository only; the published package excludes them (see the appendix note) |
393
424
  | `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 |
394
425
  | `tests/*.js` | development | never runs in production; executed by `npm test` / `node --test` |
395
426
 
@@ -405,7 +436,7 @@ dsh-hitl/
405
436
 
406
437
  ```sh
407
438
  node --check index.js client.js lib/*.js # syntax
408
- npm test # 151 zero-dependency cases: pure functions, state machines, the channel
439
+ npm test # 153 zero-dependency cases: pure functions, state machines, the channel
409
440
  npm run check # both of the above (syntax + the whole suite)
410
441
  ```
411
442
 
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,18 +29,21 @@
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
 
40
41
  ## 1. 安装
41
42
 
42
43
  ```sh
44
+ # 从 npm 装(推荐方式)
45
+ dsh plugin --profile <你的 profile> add dsh-hitl
46
+
43
47
  # 直接从 GitHub 装(无需构建)
44
48
  dsh plugin --profile <你的 profile> add github:YunpengDon/dsh-hitl
45
49
 
@@ -152,7 +156,7 @@ export function apply(ctx) {
152
156
  | `reject.feedbackPrompt` | string | 内置文案 | 反馈栏 placeholder |
153
157
  | `reject.requireFeedback` | boolean | `false` | 反馈必填(为空时拒绝按钮会提示) |
154
158
  | `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | 用户改了文本后点【修改】的语义,见 §5 |
155
- | `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为(`reject` = fail-closed) |
159
+ | `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
156
160
  | `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
157
161
  | `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
158
162
 
@@ -337,6 +341,21 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
337
341
  想让它在无人值守时一直等,把 `whenUnavailable` 设为 `wait`。
338
342
  - 插件在没有任何 Web 服务的组合里(headless/TUI)**仍然激活并继续拦**——"没有 UI 就不拦"不是本插件的语义。
339
343
 
344
+ ### 7.1 安全边界:HITL 拦得住什么、拦不住什么
345
+
346
+ - **它拦的是"这一次调用在真正执行之前",不是世界状态。** 如果工具在自己的执行层已经产生了副作用(并发竞态、异步落盘、
347
+ 已经发出去的外部请求),拒绝只能阻止"接下来",回滚不了"已经"——**HITL 不是事务,也不是沙箱**。
348
+ - 所以实际安全程度取决于两件事:**① 你的 matcher 覆盖了多危险的操作;② 工具是否把不可逆动作放在最后一刻。**
349
+ 把 `bash` / `write` / `edit` 这类"入口即危险"的工具挂上是对的;若某个工具的风险要到执行中途才出现,HITL 只能替你决定"要不要开始"。
350
+ - **用户的同意 ≠ 越权**:同意之后沙箱、guard、hooks、审批照常随后生效(§8);反过来,别的执行前策略也可能**先于**本插件拒绝——
351
+ 实测过一次:`fs-observation-policy` 在用户点【同意】之前就把调用挡了,HITL 连面板都没弹(见 §8)。
352
+ - **`whenUnavailable: wait` 是 fail-open**:没有任何浏览器连接时会一直等,没配 `countdown` 就是等到会话结束。
353
+ 想让"无人值守也不卡死",用默认的 `reject`,或者 `wait` + `countdown`(挂载时宿主会对这种组合给出警告)。
354
+ - **本机信任边界**:决策通道与 `/status` 都只认回环地址。同机任意进程可以读 `/status`,它只含**挂载列表**与**待决策的
355
+ 工具名 / 会话 id / 剩余毫秒 / 是否暂停**——**没有工具参数,也没有令牌**。要把这个口子量准:同机进程本来就能读
356
+ `~/.dsh/sessions/**` 的会话日志,而那里有完整的工具参数,所以 `/status` 的暴露面是它严格更小的子集。
357
+ 这个口子是有意保留的:没有它,在终端里回答"HITL 现在拦着什么"就只能靠翻页面。
358
+
340
359
  ---
341
360
 
342
361
  ## 8. 与其他执行前策略的关系
@@ -344,6 +363,9 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
344
363
  - 本插件用 `tools/pre-execute` 且 `prepend: true`,也就是**先问人**;用户同意后,
345
364
  沙箱、guard、hooks、审批等其它策略照常随后生效。**用户的同意不等于越权**。
346
365
  - 因此可能出现"用户同意了,但工具仍被沙箱/守卫拒绝"——这是设计使然,不是 bug。
366
+ - **反过来也成立**:别的执行前策略可能在**本插件之前**就拒绝,此时 HITL 面板根本不会出现——所以"没弹面板就直接执行了/就失败了"
367
+ 两种现象都要先看别的策略。实测过一次:`fs-observation-policy` 要求"重写已删除文件前先重读",
368
+ 它在用户点【同意】**之后**把调用挡了下来(面板照常弹过),也在另一轮里**先于**本插件直接拒绝(面板没弹)。
347
369
  - 本插件只做"执行前问人",不改变工具参数、不改变工具结果。
348
370
 
349
371
  ---
@@ -351,6 +373,7 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
351
373
  ## 9. 已知限制
352
374
 
353
375
  - **不能改写工具参数**(见 §5),所以【修改】默认走"拒绝并交回修改内容"。
376
+ - **HITL 不是副作用边界**(见 §7.1):它拦的是"调用开始之前",回滚不了已经发生的副作用;安全性取决于 matcher 覆盖面与工具自身的设计。
354
377
  - **挂载文案不本地化**:`title` / `labels` / 字段标题都是挂载方给的字符串,本插件不翻译,也不接受 `{zh, en}` 映射(见 §6.3)。
355
378
  - **不写会话审计事件**:DSH 不允许插件追加新的事件类型,决策痕迹只存在于工具结果(和 `allow-and-inform` 的那条 user 消息)里。
356
379
  - **只有 Web 界面有面板**:ACP/TUI/headless 下按 `whenUnavailable` 处理(默认拒绝)。
@@ -425,28 +448,30 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
425
448
 
426
449
  ```text
427
450
  dsh-hitl/
428
- ├── index.js 629 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
451
+ ├── index.js 639 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
429
452
  ├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
430
453
  ├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
431
454
  │ ├── protocol.js 182 帧与上行校验、上限、错误码(协议规范的唯一出处)
432
- │ ├── resolve.js 302 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
455
+ │ ├── resolve.js 311 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
433
456
  │ ├── fields.js 244 默认提案推导、diff 配对、决策 → 模型可见文本
434
457
  │ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
435
458
  ├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
436
459
  │ ├── en.json 6
437
460
  │ └── zh.json 6
438
- ├── docs/ 上文那些截图,中英各一套(-cn / -en)
439
- ├── tests/ 零依赖 Node 测试,共 151 个用例
461
+ ├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
462
+ ├── tests/ 零依赖 Node 测试,共 153 个用例
440
463
  │ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
441
464
  │ ├── fields.test.js 269 (26)提案推导与决策文本
442
465
  │ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
443
- │ ├── resolve.test.js 162 (18)matcher 与选项归一化
466
+ │ ├── resolve.test.js 181 (19)matcher 与选项归一化
444
467
  │ ├── protocol.test.js 117 (16)帧与上行校验
445
- │ ├── docs.test.js 116 (5)两份 README、互链,以及上面这张表里的每个数字
446
- │ └── manifest.test.js 61 (3)包元数据与 locale 资源形状
447
- ├── package.json 69 清单:exports / dsh.bundle.patch / dsh.client / icon
468
+ │ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
469
+ │ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
470
+ ├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
471
+ ├── package.json 68 清单:exports / dsh.bundle.patch / dsh.client / icon
448
472
  ├── cordis.patch.yml 17 bundle 的配置层(插入 id 为 hitl 的那一行)
449
473
  ├── icon.svg 6 插件列表图标
474
+ ├── LICENSE MIT
450
475
  ├── README.md 本文档(英文版)
451
476
  └── README.zh.md 本文档的中文版
452
477
  ```
@@ -458,7 +483,7 @@ dsh-hitl/
458
483
  | `index.js`、`lib/*.js` | 宿主半(Node) | 必须**重启 dsh**。Node 的 ESM 模块缓存不会因为"禁用→启用这一行"而失效 |
459
484
  | `client.js` | 浏览器半 | **客户端热重载**自动拾取(宿主每 500ms stat 一次各行的 client 产物并广播 `/plugins/events`),不用刷新页面 |
460
485
  | `locale/*.json` | 插件列表元数据 | 插件管理器重读元数据时生效(打开插件页 / 重启 dsh) |
461
- | `docs/*.png` | 文档 | README 渲染时 |
486
+ | `docs/*.png` | 截图源文件 | 只留在仓库里;发布的包里不再包含(见附录说明) |
462
487
  | `package.json`、`cordis.patch.yml` | 清单与配置层 | 需要重启或重新加载该行;已安装包不会被就地替换 |
463
488
  | `tests/*.js` | 开发期 | 不参与运行,只由 `npm test` / `node --test` 执行 |
464
489
 
@@ -480,7 +505,7 @@ dsh-hitl/
480
505
 
481
506
  ```sh
482
507
  node --check index.js client.js lib/*.js # 语法
483
- npm test # 151 个纯函数/状态机/通道用例,零依赖
508
+ npm test # 153 个纯函数/状态机/通道用例,零依赖
484
509
  npm run check # 上面两步合起来(语法 + 全部用例)
485
510
  ```
486
511
 
package/index.js CHANGED
@@ -107,7 +107,14 @@ export function apply(ctx, config = {}) {
107
107
  const clients = new Set()
108
108
  const resolvers = new Map()
109
109
  const cleanups = new Map()
110
- const revisions = new Map()
110
+ // Revision context for `allow-and-inform`, keyed by the execution object the
111
+ // pipeline carries from `tools/pre-execute` through `tools/post-execute`.
112
+ // A WeakMap, not a callId-keyed Map: if a call is allowed and then never
113
+ // reaches post-execute (an aborted turn, a process torn down mid-execution),
114
+ // a Map entry would outlive the call for the rest of the process. Keyed by
115
+ // the execution itself, the entry dies with the call that owns it, so there
116
+ // is nothing to leak and nothing to remember to clean up.
117
+ const revisions = new WeakMap()
111
118
 
112
119
  const registry = createPendingRegistry({
113
120
  holdGraceMs: positiveInteger(options.holdGraceMs, DEFAULT_HOLD_GRACE_MS),
@@ -293,7 +300,9 @@ export function apply(ctx, config = {}) {
293
300
  if (settlement.decision.kind === 'cancel') return { kind: 'cancel' }
294
301
  const revising = isRevision(settlement.decision)
295
302
  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 })
303
+ // The call is about to run with its original arguments; the edit rides
304
+ // along as context on the result instead (see the post-execute listener).
305
+ revisions.set(exec, { request, decision: settlement.decision })
297
306
  return { kind: 'allow' }
298
307
  }
299
308
  const info = settlement.source === OUTCOMES.timeout
@@ -342,17 +351,17 @@ export function apply(ctx, config = {}) {
342
351
 
343
352
  /**
344
353
  * 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.
354
+ * the `allow-and-inform` modify mode. Lookup is by the execution object, so a
355
+ * call that never reaches this stage costs nothing but its own lifetime; the
356
+ * delete keeps a second post-execute for the same execution from attaching
357
+ * the same context twice.
348
358
  */
349
359
  ctx.on('tools/post-execute', async (exec, result, next) => {
350
360
  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)
361
+ const revision = revisions.get(exec)
354
362
  if (revision === undefined) return decision
355
- revisions.delete(key)
363
+ revisions.delete(exec)
364
+ const label = exec.callId === undefined ? String(exec.name) : String(exec.callId)
356
365
  try {
357
366
  const message = {
358
367
  id: randomUUID(),
@@ -362,7 +371,7 @@ export function apply(ctx, config = {}) {
362
371
  }
363
372
  return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), message] }
364
373
  } catch (error) {
365
- warn(`the revision context for ${key} was dropped: ${String(error)}`)
374
+ warn(`the revision context for ${label} was dropped: ${String(error)}`)
366
375
  return decision
367
376
  }
368
377
  })
@@ -622,7 +631,8 @@ export function apply(ctx, config = {}) {
622
631
  }
623
632
  }
624
633
  mounts.length = 0
625
- revisions.clear()
634
+ // `revisions` is a WeakMap: shutdown has nothing to clear, and a call that
635
+ // never came back takes its own entry with it.
626
636
  resolvers.clear()
627
637
  cleanups.clear()
628
638
  }, 'dsh-hitl: shutdown')
package/lib/resolve.js CHANGED
@@ -216,6 +216,17 @@ export function normalizeMount(input, source) {
216
216
  const labels = Array.isArray(input.labels)
217
217
  ? input.labels.filter(label => typeof label === 'string' && label !== '')
218
218
  : []
219
+ const countdown = normalizeCountdown(input.countdown, report)
220
+ const unavailable = WHEN_UNAVAILABLE.includes(whenUnavailable)
221
+ ? whenUnavailable
222
+ : DEFAULT_OPTIONS.whenUnavailable
223
+ // `wait` is fail-open by design: with no browser attached the call sits there
224
+ // until something ends it. Without a countdown, "something" means the session
225
+ // or the plugin, so an unattended run blocks an agent for as long as it lives.
226
+ // Say so at mount time rather than letting the first unattended run discover it.
227
+ if (unavailable === 'wait' && countdown === null) {
228
+ report('whenUnavailable: wait without a countdown blocks an unattended call until its session ends; pair it with countdown.seconds or use the fail-closed default')
229
+ }
219
230
  const mount = {
220
231
  describe: matcher.describe,
221
232
  test: matcher.test,
@@ -226,12 +237,10 @@ export function normalizeMount(input, source) {
226
237
  buttons: normalizeButtons(input.buttons, report),
227
238
  fields,
228
239
  diff: input.diff,
229
- countdown: normalizeCountdown(input.countdown, report),
240
+ countdown,
230
241
  reject: normalizeReject(input.reject, report),
231
242
  modify: normalizeModify(input.modify, report),
232
- whenUnavailable: WHEN_UNAVAILABLE.includes(whenUnavailable)
233
- ? whenUnavailable
234
- : DEFAULT_OPTIONS.whenUnavailable,
243
+ whenUnavailable: unavailable,
235
244
  enabled: input.enabled === undefined ? true : input.enabled,
236
245
  maxFieldChars,
237
246
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hitl",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI, with an optional countdown.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -39,7 +39,6 @@
39
39
  "lib",
40
40
  "tests",
41
41
  "locale",
42
- "docs",
43
42
  "icon.svg",
44
43
  "cordis.patch.yml",
45
44
  "README.md",
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url'
15
15
  */
16
16
  const root = join(dirname(fileURLToPath(import.meta.url)), '..')
17
17
 
18
+ const LANDING_PAGE = 'https://yunpengdon.github.io/dsh-hitl-landing/'
19
+
18
20
  /** The English README is the package's front page; the Chinese one is its twin. */
19
21
  const READMES = [
20
22
  { language: 'en', file: 'README.md', other: 'README.zh.md', suffix: '-en' },
@@ -62,21 +64,22 @@ describe('docs: the English and Chinese READMEs', () => {
62
64
  }
63
65
  })
64
66
 
65
- it('shows every screenshot, and only its own language', () => {
66
- const referenced = new Set()
67
- const available = new Set(readdirSync(join(root, 'docs')))
67
+ it('sends the reader to the landing page instead of shipping screenshots', () => {
68
+ // The four screenshots used to be the bulk of the published tarball. They
69
+ // live on the landing page now (its own repository carries them, in both
70
+ // languages), so the READMEs must link it and must not reference a local
71
+ // copy — and the package must not carry one either.
72
+ const manifest = JSON.parse(read('package.json'))
73
+ assert.equal(manifest.files.includes('docs'), false, 'screenshots must stay out of the tarball')
68
74
  for (const readme of READMES) {
69
- const images = [...read(readme.file).matchAll(/!\[[^\]]*\]\((docs\/[^)]+)\)/g)].map(match => match[1])
70
- assert.equal(images.length, 4, `${readme.file} must place all four screenshots`)
71
- for (const image of images) {
72
- const name = image.slice('docs/'.length)
73
- assert.equal(available.has(name), true, `${readme.file} references a missing screenshot: ${image}`)
74
- assert.equal(name.endsWith(`${readme.suffix}.png`), true, `${readme.file} must use its ${readme.suffix} screenshots`)
75
- referenced.add(name)
76
- }
77
- }
78
- for (const name of available) {
79
- assert.equal(referenced.has(name), true, `docs/${name} is shipped but never shown`)
75
+ const text = read(readme.file)
76
+ assert.equal(text.includes(LANDING_PAGE), true, `${readme.file} must link the landing page`)
77
+ assert.equal(
78
+ /!\[[^\]]*\]\(docs\//.test(text), false,
79
+ `${readme.file} must not embed a local screenshot`,
80
+ )
81
+ const header = text.split('\n').slice(0, 8).join('\n')
82
+ assert.equal(header.includes(LANDING_PAGE), true, `${readme.file} must offer the tour near the top`)
80
83
  }
81
84
  })
82
85
 
@@ -108,9 +111,11 @@ describe('docs: the English and Chinese READMEs', () => {
108
111
  }
109
112
  })
110
113
 
111
- it('keeps the screenshot folder shipped', () => {
114
+ it('publishes both READMEs and keeps the screenshot sources in the repository only', () => {
112
115
  const manifest = JSON.parse(read('package.json'))
113
- assert.equal(manifest.files.includes('docs'), true, '`docs` must be published with the screenshots')
114
116
  assert.equal(manifest.files.includes('README.zh.md'), true, 'the Chinese README must be published too')
117
+ // The landing page's own repository carries the screenshots, so a second
118
+ // copy inside the package would only double the download for every install.
119
+ assert.equal(manifest.files.includes('docs'), false, 'screenshot sources must stay out of the tarball')
115
120
  })
116
121
  })
@@ -24,6 +24,22 @@ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
24
24
  const LANGUAGES = ['en', 'zh']
25
25
 
26
26
  describe('manifest: what a profile reads about this plugin', () => {
27
+ it('keeps the tag-triggered release on stage-only trusted publishing', () => {
28
+ const workflow = readFileSync(join(root, '.github', 'workflows', 'release.yml'), 'utf8')
29
+ assert.equal(workflow.includes("tags: ['v*']"), true, 'the workflow must trigger on version tags')
30
+ assert.equal(workflow.includes('id-token: write'), true, 'OIDC publishing needs the id-token permission')
31
+ assert.equal(workflow.includes('contents: read'), true)
32
+ // Stage-only: the workflow may upload a release, but a maintainer approves
33
+ // it with 2FA before it becomes public, so it must never publish directly.
34
+ assert.equal(/run: npm stage publish/.test(workflow), true, 'the release step must stage')
35
+ assert.equal(/run: npm publish\b/.test(workflow), false, 'a direct publish would need "publish directly" granted')
36
+ // Provenance is automatic under trusted publishing — but this file *talks*
37
+ // about that, so assert on the command, not on the words.
38
+ assert.equal(/npm stage publish[^\n]*--provenance/.test(workflow), false, 'no --provenance flag on the command')
39
+ // npm matches the configured trusted publisher against this exact filename.
40
+ assert.equal(workflow.includes('release.yml'), true, 'the trusted publisher is configured with this filename')
41
+ })
42
+
27
43
  it('resolves the resources the Loader asks for by name', () => {
28
44
  assert.equal(manifest.name, 'dsh-hitl')
29
45
  assert.equal(typeof manifest.description, 'string')
@@ -79,6 +79,25 @@ describe('resolve: normalizeMount', () => {
79
79
  assert.match(warnings[0], /countdown.seconds/)
80
80
  })
81
81
 
82
+ it('warns when a mount waits forever with nothing to end the wait', () => {
83
+ // Fail-open plus no deadline is legal, and it blocks an unattended call for
84
+ // as long as its session lives. The mount is still installed; the warning is
85
+ // the whole point, because the first unattended run must not be the discovery.
86
+ const open = normalizeMount({ tool: 'bash', whenUnavailable: 'wait' }, 'src')
87
+ assert.equal(open.ok, true)
88
+ assert.equal(open.mount.options.whenUnavailable, 'wait')
89
+ assert.equal(open.warnings.length, 1)
90
+ assert.match(open.warnings[0], /whenUnavailable: wait without a countdown/)
91
+
92
+ // A deadline is what bounds it, so the pairing is silent — and so is the
93
+ // fail-closed default, which needs no deadline to be safe.
94
+ const bounded = normalizeMount({ tool: 'bash', whenUnavailable: 'wait', countdown: { seconds: 300, action: 'reject' } }, 'src')
95
+ assert.equal(bounded.warnings.length, 0)
96
+ const closed = normalizeMount({ tool: 'bash' }, 'src')
97
+ assert.equal(closed.mount.options.whenUnavailable, 'reject')
98
+ assert.equal(closed.warnings.length, 0)
99
+ })
100
+
82
101
  it('normalizes field entries and drops invalid ones', () => {
83
102
  const { mount, warnings } = normalizeMount({
84
103
  tool: 'edit',
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file