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/LICENSE +21 -0
- package/README.md +415 -0
- package/README.zh.md +490 -0
- package/client.js +1918 -0
- package/cordis.patch.yml +17 -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/icon.svg +6 -0
- package/index.js +629 -0
- package/lib/fields.js +244 -0
- package/lib/pending.js +217 -0
- package/lib/protocol.js +182 -0
- package/lib/resolve.js +302 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -0
- package/tests/client.test.js +818 -0
- package/tests/docs.test.js +116 -0
- package/tests/fields.test.js +269 -0
- package/tests/manifest.test.js +61 -0
- package/tests/pending.test.js +240 -0
- package/tests/protocol.test.js +117 -0
- package/tests/resolve.test.js +162 -0
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
package/locale/zh.json
ADDED
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
|
+
}
|