dsh-hitl 0.1.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/lib/resolve.js ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Mount resolution: tool matchers, option defaults, and the diagnostics that
3
+ * turn a malformed mount into one warning row instead of a thrown load.
4
+ *
5
+ * A mount is what a plugin passes to `ctx.hitl.protect(matcher, options)` or a
6
+ * user writes as one `config.protect` entry. Normalizing here keeps the gate
7
+ * free of policy: it only ever asks "does this call match, and with what
8
+ * options".
9
+ */
10
+
11
+ import { LIMITS } from './protocol.js'
12
+
13
+ /** Panel layouts a mount may request. */
14
+ export const LAYOUTS = ['stacked', 'split']
15
+
16
+ /** What the human's modification means once the gate answers. */
17
+ export const MODIFY_MODES = ['revise-request', 'allow-and-inform']
18
+
19
+ /** What a countdown does when it reaches zero. */
20
+ export const TIMEOUT_ACTIONS = ['approve', 'reject', 'notify']
21
+
22
+ /** What a protected call does when no decision UI is connected. */
23
+ export const WHEN_UNAVAILABLE = ['reject', 'wait']
24
+
25
+ /** Field renderers the browser half knows. */
26
+ export const RENDER_MODES = ['markdown', 'text', 'diff', 'json', 'hidden']
27
+
28
+ /** Field renderers the human may edit. */
29
+ const EDITABLE_RENDERS = ['markdown', 'text']
30
+
31
+ /** Defaults one mount starts from; every key is restated by {@link normalizeMount}. */
32
+ export const DEFAULT_OPTIONS = {
33
+ title: undefined,
34
+ layout: 'stacked',
35
+ labels: [],
36
+ buttons: {},
37
+ fields: undefined,
38
+ diff: undefined,
39
+ countdown: null,
40
+ reject: { feedback: false, feedbackPrompt: undefined, requireFeedback: false },
41
+ modify: { mode: 'revise-request' },
42
+ whenUnavailable: 'reject',
43
+ enabled: true,
44
+ maxFieldChars: LIMITS.maxFieldChars,
45
+ }
46
+
47
+ /**
48
+ * Turn one glob-ish tool pattern into an anchored regular expression.
49
+ * `*` matches any run of characters; every other character is literal.
50
+ * @param pattern - e.g. `mcp__*`.
51
+ * @returns an anchored RegExp over tool names.
52
+ */
53
+ export function globToRegExp(pattern) {
54
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, character => `\\${character}`)
55
+ return new RegExp(`^${escaped.replaceAll('\\*', '.*')}$`)
56
+ }
57
+
58
+ /**
59
+ * Compile any accepted matcher form into a test over one pending call.
60
+ * @param matcher - tool name, glob string, name list, RegExp, or predicate over the execution.
61
+ * @returns a predicate plus a stable description used by `ctx.hitl.list()`.
62
+ * @throws {TypeError} when the matcher form is not one of the accepted ones.
63
+ */
64
+ export function createMatcher(matcher) {
65
+ if (typeof matcher === 'string') {
66
+ if (matcher === '') throw new TypeError('hitl: a tool matcher must not be an empty string')
67
+ if (matcher.includes('*')) {
68
+ const pattern = globToRegExp(matcher)
69
+ return { describe: matcher, test: execution => pattern.test(execution.name) }
70
+ }
71
+ return { describe: matcher, test: execution => execution.name === matcher }
72
+ }
73
+ if (matcher instanceof RegExp) {
74
+ return { describe: String(matcher), test: execution => matcher.test(execution.name) }
75
+ }
76
+ if (Array.isArray(matcher)) {
77
+ const compiled = matcher.map(entry => createMatcher(entry))
78
+ return {
79
+ describe: matcher.map(entry => `${String(entry)}`).join(', '),
80
+ test: execution => compiled.some(entry => entry.test(execution)),
81
+ }
82
+ }
83
+ if (typeof matcher === 'function') {
84
+ return { describe: `predicate(${matcher.name || 'anonymous'})`, test: execution => matcher(execution) === true }
85
+ }
86
+ throw new TypeError('hitl: matcher must be a tool name, glob, RegExp, array, or predicate')
87
+ }
88
+
89
+ function normalizeCountdown(value, report) {
90
+ if (value === undefined || value === null || value === false) return null
91
+ if (typeof value !== 'object' || Array.isArray(value)) {
92
+ report('countdown must be an object like { seconds, action }')
93
+ return null
94
+ }
95
+ const seconds = value.seconds
96
+ if (!Number.isSafeInteger(seconds) || seconds < 1) {
97
+ report('countdown.seconds must be a positive integer number of seconds')
98
+ return null
99
+ }
100
+ const action = value.action ?? 'reject'
101
+ if (!TIMEOUT_ACTIONS.includes(action)) {
102
+ report(`countdown.action must be one of ${TIMEOUT_ACTIONS.join(', ')}`)
103
+ return null
104
+ }
105
+ return {
106
+ seconds,
107
+ action,
108
+ freezeOnInteract: value.freezeOnInteract !== false,
109
+ }
110
+ }
111
+
112
+ function normalizeReject(value, report) {
113
+ if (value === undefined || value === null) return { ...DEFAULT_OPTIONS.reject }
114
+ if (typeof value !== 'object' || Array.isArray(value)) {
115
+ report('reject must be an object like { feedback: true }')
116
+ return { ...DEFAULT_OPTIONS.reject }
117
+ }
118
+ return {
119
+ feedback: value.feedback === true,
120
+ feedbackPrompt: typeof value.feedbackPrompt === 'string' ? value.feedbackPrompt : undefined,
121
+ requireFeedback: value.requireFeedback === true,
122
+ }
123
+ }
124
+
125
+ function normalizeModify(value, report) {
126
+ if (value === undefined || value === null) return { ...DEFAULT_OPTIONS.modify }
127
+ if (typeof value !== 'object' || Array.isArray(value)) {
128
+ report('modify must be an object like { mode: "revise-request" }')
129
+ return { ...DEFAULT_OPTIONS.modify }
130
+ }
131
+ const mode = value.mode ?? 'revise-request'
132
+ if (!MODIFY_MODES.includes(mode)) {
133
+ report(`modify.mode must be one of ${MODIFY_MODES.join(', ')}`)
134
+ return { ...DEFAULT_OPTIONS.modify }
135
+ }
136
+ return { mode }
137
+ }
138
+
139
+ /** Button copy a mount may override; the browser localizes every label it leaves unset. */
140
+ function normalizeButtons(value, report) {
141
+ if (value === undefined || value === null) return {}
142
+ if (typeof value !== 'object' || Array.isArray(value)) {
143
+ report('buttons must be an object like { approve: "同意", reject: "拒绝" }')
144
+ return {}
145
+ }
146
+ const buttons = {}
147
+ for (const key of ['approve', 'modify', 'reject']) {
148
+ if (typeof value[key] === 'string' && value[key] !== '') buttons[key] = value[key]
149
+ }
150
+ return buttons
151
+ }
152
+
153
+ function normalizeField(entry, report, index) {
154
+ if (typeof entry === 'string') return { param: entry }
155
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
156
+ report(`fields[${index}] must be a parameter name or an object`)
157
+ return undefined
158
+ }
159
+ if (typeof entry.param !== 'string' || entry.param === '') {
160
+ report(`fields[${index}].param must be a non-empty parameter name`)
161
+ return undefined
162
+ }
163
+ if (entry.render !== undefined && !RENDER_MODES.includes(entry.render)) {
164
+ report(`fields[${index}].render must be one of ${RENDER_MODES.join(', ')}`)
165
+ return undefined
166
+ }
167
+ return {
168
+ param: entry.param,
169
+ ...(typeof entry.title === 'string' ? { title: entry.title } : {}),
170
+ ...(typeof entry.description === 'string' ? { description: entry.description } : {}),
171
+ ...(entry.render === undefined ? {} : { render: entry.render }),
172
+ ...(entry.editable === undefined ? {} : { editable: entry.editable === true }),
173
+ ...(Array.isArray(entry.labels) ? { labels: entry.labels.filter(label => typeof label === 'string') } : {}),
174
+ ...(entry.diff === undefined ? {} : { diff: entry.diff }),
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Validate and complete one mount.
180
+ * @param input - `{ tool, ...options }`, the row form; or `{ matcher, options }`, the API form.
181
+ * @param source - stable label for diagnostics (`config.protect[0]`, `hitl.protect()`).
182
+ * @returns `{ ok: true, mount }` or `{ ok: false, warnings }`.
183
+ */
184
+ export function normalizeMount(input, source) {
185
+ const warnings = []
186
+ const report = message => { warnings.push(`${source}: ${message}`) }
187
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
188
+ report('a mount must be an object')
189
+ return { ok: false, warnings }
190
+ }
191
+ const rawMatcher = input.matcher ?? input.tool
192
+ let matcher
193
+ try {
194
+ matcher = createMatcher(rawMatcher)
195
+ } catch (error) {
196
+ report(error instanceof Error ? error.message : String(error))
197
+ return { ok: false, warnings }
198
+ }
199
+ const layout = input.layout ?? DEFAULT_OPTIONS.layout
200
+ if (!LAYOUTS.includes(layout)) report(`layout must be one of ${LAYOUTS.join(', ')}`)
201
+ const whenUnavailable = input.whenUnavailable ?? DEFAULT_OPTIONS.whenUnavailable
202
+ if (!WHEN_UNAVAILABLE.includes(whenUnavailable)) {
203
+ report(`whenUnavailable must be one of ${WHEN_UNAVAILABLE.join(', ')}`)
204
+ }
205
+ const fields = input.fields === undefined
206
+ ? undefined
207
+ : (Array.isArray(input.fields) ? input.fields : [])
208
+ .map((entry, index) => normalizeField(entry, report, index))
209
+ .filter(entry => entry !== undefined)
210
+ if (input.fields !== undefined && !Array.isArray(input.fields)) {
211
+ report('fields must be an array of parameter names or field objects')
212
+ }
213
+ const maxFieldChars = Number.isSafeInteger(input.maxFieldChars) && input.maxFieldChars > 0
214
+ ? input.maxFieldChars
215
+ : DEFAULT_OPTIONS.maxFieldChars
216
+ const labels = Array.isArray(input.labels)
217
+ ? input.labels.filter(label => typeof label === 'string' && label !== '')
218
+ : []
219
+ const mount = {
220
+ describe: matcher.describe,
221
+ test: matcher.test,
222
+ options: {
223
+ title: typeof input.title === 'string' ? input.title : undefined,
224
+ layout: LAYOUTS.includes(layout) ? layout : DEFAULT_OPTIONS.layout,
225
+ labels,
226
+ buttons: normalizeButtons(input.buttons, report),
227
+ fields,
228
+ diff: input.diff,
229
+ countdown: normalizeCountdown(input.countdown, report),
230
+ reject: normalizeReject(input.reject, report),
231
+ modify: normalizeModify(input.modify, report),
232
+ whenUnavailable: WHEN_UNAVAILABLE.includes(whenUnavailable)
233
+ ? whenUnavailable
234
+ : DEFAULT_OPTIONS.whenUnavailable,
235
+ enabled: input.enabled === undefined ? true : input.enabled,
236
+ maxFieldChars,
237
+ },
238
+ }
239
+ return { ok: true, mount, warnings }
240
+ }
241
+
242
+ /**
243
+ * Normalize a whole `config.protect` list, keeping the valid rows.
244
+ * @param entries - the row's raw list.
245
+ * @param source - label prefix for diagnostics.
246
+ * @returns `{ mounts, warnings }`.
247
+ */
248
+ export function normalizeProtectList(entries, source = 'config.protect') {
249
+ const mounts = []
250
+ const warnings = []
251
+ if (entries === undefined) return { mounts, warnings }
252
+ if (!Array.isArray(entries)) {
253
+ warnings.push(`${source} must be an array of { tool, ...options } entries`)
254
+ return { mounts, warnings }
255
+ }
256
+ for (const [index, entry] of entries.entries()) {
257
+ const normalized = normalizeMount(entry, `${source}[${index}]`)
258
+ warnings.push(...normalized.warnings)
259
+ if (normalized.ok) mounts.push(normalized.mount)
260
+ }
261
+ return { mounts, warnings }
262
+ }
263
+
264
+ /**
265
+ * Find the mount that owns one call. The most recently registered match wins,
266
+ * so a plugin mounting a tool after the row config overrides the row.
267
+ * @param mounts - registered mounts in registration order.
268
+ * @param execution - the pending tool execution.
269
+ * @returns the winning mount, or undefined.
270
+ */
271
+ export function matchMount(mounts, execution) {
272
+ for (let index = mounts.length - 1; index >= 0; index -= 1) {
273
+ const mount = mounts[index]
274
+ if (mount.test(execution)) return mount
275
+ }
276
+ return undefined
277
+ }
278
+
279
+ /**
280
+ * Resolve whether a mount protects this call right now.
281
+ * @param mount - the matched mount.
282
+ * @param execution - the pending tool execution.
283
+ * @returns whether the gate must ask.
284
+ */
285
+ export function mountApplies(mount, execution) {
286
+ const enabled = mount.options.enabled
287
+ if (enabled === undefined || enabled === true) return true
288
+ if (enabled === false) return false
289
+ if (typeof enabled === 'function') {
290
+ try {
291
+ return enabled(execution) === true
292
+ } catch {
293
+ return false
294
+ }
295
+ }
296
+ return false
297
+ }
298
+
299
+ /** Whether a render mode is editable by default. */
300
+ export function defaultEditable(render) {
301
+ return EDITABLE_RENDERS.includes(render)
302
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "HITL human decision",
4
+ "description": "Mount a human decision on any tool: title, proposal (diff / text / markdown), approve / modify / reject, and a configurable countdown."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "HITL 人工决策",
4
+ "description": "给任意工具挂上人工决策:标题 + 待决策提案(diff / text / markdown)+ 同意·修改·拒绝 + 可配置倒计时。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "dsh-hitl",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "dsh",
7
+ "deepseek-harness",
8
+ "hitl",
9
+ "human-in-the-loop",
10
+ "approval",
11
+ "ai-agent",
12
+ "web-ui",
13
+ "plugin",
14
+ "bundle",
15
+ "cordis-plugin"
16
+ ],
17
+ "homepage": "https://github.com/YunpengDon/dsh-hitl#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/YunpengDon/dsh-hitl/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/YunpengDon/dsh-hitl.git"
24
+ },
25
+ "author": "YunpengDon",
26
+ "license": "MIT",
27
+ "icon": "./icon.svg",
28
+ "type": "module",
29
+ "main": "index.js",
30
+ "exports": {
31
+ ".": "./index.js",
32
+ "./client": "./client.js",
33
+ "./package.json": "./package.json",
34
+ "./locale/*.json": "./locale/*.json"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "client.js",
39
+ "lib",
40
+ "tests",
41
+ "locale",
42
+ "docs",
43
+ "icon.svg",
44
+ "cordis.patch.yml",
45
+ "README.md",
46
+ "README.zh.md"
47
+ ],
48
+ "engines": {
49
+ "node": "^22.19.0 || >=24.0.0"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test tests/*.test.js",
53
+ "check": "node --check index.js && node --check client.js && node --test tests/*.test.js",
54
+ "prepublishOnly": "npm run check"
55
+ },
56
+ "dsh": {
57
+ "bundle": {
58
+ "patch": "./cordis.patch.yml"
59
+ },
60
+ "client": {
61
+ "platform": "web",
62
+ "immediately": true,
63
+ "inject": [
64
+ "@deepseek-ai/dsh-client-ui-conversation",
65
+ "@deepseek-ai/dsh-client-ui-session"
66
+ ]
67
+ }
68
+ }
69
+ }