dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
English | 中文
Let the agent ask you before it acts. One config entry or one API call turns any tool call into a card that waits for your nod: read what it is about to do, then approve it, edit it, or reject it with a reason.
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.
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.
(Out-of-process backends never reach this gate — see §6.1.)
See this card on the landing page →
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】).
See the notice on the landing page →
# From npm (the canonical install)
dsh plugin --profile <your profile> add dsh-hitl
# Straight from GitHub (nothing to build)
dsh plugin --profile <your profile> add github:YunpengDon/dsh-hitl
# From a local directory (handiest while developing: restart dsh after code changes)
dsh plugin --profile <your profile> add /path/to/dsh-hitl
# Or through the GUI: Settings → Plugins → install bundle
Once installed:
window.__DSH_HITL__). A page that was already open cannot see it, even after the browser half is hot-reloaded — so the first install needs one refresh; later restarts do not.hitl row of cordis.patch.yml (next section), or let other plugins call ctx.hitl.protect(...).Uninstall:
dsh plugin --profile <profile> remove dsh-hitl(its configuration layer is removed with it).
cordis.patch.yml:
- id: hitl
name: 'dsh-hitl'
config:
protect:
- tool: bash # tool name; patterns like 'mcp__*' work too
title: Confirm before running # panel title (bold, wraps)
countdown: { seconds: 30, action: reject }
reject: { feedback: true } # show the feedback box on reject
A patch that overrides a row replaces its whole config: restate every key you still want.
export const name = 'my-plugin'
export const inject = ['hitl'] // wait for the hitl service before applying
export function apply(ctx) {
// Passing your own ctx as the third argument binds the mount to this plugin's lifetime
ctx.hitl.protect('bash', {
title: 'Run this command?',
countdown: { seconds: 30, action: 'reject' },
reject: { feedback: true },
}, ctx)
ctx.hitl.protect(['write', 'edit'], { // arrays, globs, RegExp, and predicates all work
title: 'Confirm before editing',
diff: { path: 'file_path', before: 'old_string', after: 'new_string' }, // folded into a side-by-side diff
countdown: null, // no countdown = wait forever (still cancellable)
}, ctx)
}
ownerdecides whose account this mount's revocation is filed under. It is the calling plugin's Cordis context — normally thectxyourapplyreceived: pass it andhitlfiles the mount's release on the calling plugin's fiber, so the mount is removed automatically when that plugin is unloaded, disabled, or reloaded. The mount itself is state owned by thehitlplugin, not by yours — so without an owner the disposerprotect()returns is the only way to unbind, and "return a function fromapply" is not a lifetime: it was measured that the mount outlives the unloaded plugin row, leaving that tool blocked forever. The equivalent explicit form is to wrap it in your own effect:ctx.effect(() => ctx.hitl.protect('bash', {...}), 'my-plugin: hitl mount'). If a mount does linger, look atctx.hitl.list()and clear it withctx.hitl.unprotect('bash'), or toggle thehitlrow off and on in the plugins page (which rebuilds the plugin's state).
| Member | Purpose |
|---|---|
protect(matcher, options?, owner?) | Mount one tool; returns a disposer. Pass the caller's own ctx as owner to file this mount's unbinding under that plugin's lifetime: the mount is removed automatically when the caller is unloaded, disabled, or reloaded. Omit it and the mount outlives its caller |
unprotect(matcher) | Remove the mounts a matcher describes; returns how many were removed |
list() | Every current mount (diagnostics) |
pending() | Every request currently waiting for a human (diagnostics) |
owneris optional in the signature because there is a second entry point: the rows inconfig.protectbelong to thehitlplugin itself (they are cleared when that row unloads) and need no owner. On thectx.hitl.protect()API, however, it is required in practice — omitting it is not a legal simplification but a trap.
matcher: a tool name / a glob string containing * / a RegExp / an array of those / (exec) => boolean.
When one tool is mounted several times, the most recent mount wins (the config row registers first and plugins later — so a plugin can override the config).
Without fields, the proposal is every parameter of this call:
title when the tool schema provides one);markdown / text are editable by default; diff / json / hidden are always read-only;(arguments) field;Confirm how <toolName> runs (and follows the interface language).protect()'s second argument / each entry of the config row)| Key | Type | Default | Purpose |
|---|---|---|---|
tool / matcher | string | RegExp | array | function | required | The tools to protect |
title | string | see §3 | Panel title (bold, wraps) |
layout | stacked | split | stacked | Proposal layout: stacked vertically / side by side |
labels | string[] | [] | Panel-level labels (zero or more) |
buttons | {approve?, modify?, reject?} | follows the language | Button copy overrides |
fields | FieldSpec[] | all parameters | Which parameters to show, and how |
diff | {path?, before, after} | none | Fold two parameters into one read-only side-by-side diff |
countdown | {seconds, action, freezeOnInteract?} | null | false | null | Countdown and timeout behaviour; null/false = no deadline |
reject.feedback | boolean | false | Whether to show the rejection feedback box |
reject.feedbackPrompt | string | built-in copy | Feedback box placeholder |
reject.requireFeedback | boolean | false | Feedback is required (an empty box is refused with a hint) |
modify.mode | revise-request | allow-and-inform | revise-request | What 【Modify】 means after an edit; see §5 |
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 |
enabled | boolean | (exec) => boolean | true | Turn a mount off temporarily / make it conditional |
maxFieldChars | number | 20000 | Per-field render cap; longer values are truncated with a note |
resolveTimeoutMs | number | 5000 | Bound on resolving one computed field; a timeout or a throw fails that field alone, never the call |
{ param: 'content', title: 'New content', description: 'Text that will be written to the file',
render: 'markdown', // markdown | text | diff | json | hidden
editable: true, // overrides the default editability
labels: ['irreversible'], // field-level labels
value: 'fixed copy', // this field's text: a literal, or (exec) => string | Promise<string>
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
render, a field renders as a diff when one can be configured, and as a markdown input otherwise.fields is given, only the listed fields are shown (parameters you leave out never reach the panel).fields: ['command', { param: 'cwd', title: 'Working directory' }].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.
ctx.hitl.protect('update_index', {
title: 'Update the knowledge-tree index',
fields: [
{ param: 'content', title: 'New index text', render: 'markdown' },
{ param: 'index', title: 'What changes in the index file', render: 'diff', editable: false,
// `before` reads the old index from disk, `after` derives the new file text —
// neither of them is one of the call's parameters
diff: { path: 'index.md',
before: async exec => readIndexFile(exec),
after: exec => `# Knowledge tree index\n\n${exec.arguments.content}` } },
],
})
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.resolveTimeoutMs (5s by default).⚠️ …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.maxDiffLines (2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.countdown.action decides what the host does when the countdown reaches zero:
| action | Does the tool run? | What the agent sees |
|---|---|---|
approve | yes | nothing extra (the tool returns normally) |
reject | no | Error: HITL: no human decision arrived within Ns, so tool "x" did not run (timeout policy: reject). |
notify | no | Error: HITL: no human decision arrived within Ns, so tool "x" did not run. Tell the user this call timed out and wait for an explicit instruction before running it. |
freezeOnInteract (default true): as soon as the human focuses or edits any input, the client asks the host to pause the countdown; blurring resumes it. A pause has a ceiling (the plugin's holdGraceMs, 5 minutes by default) so that closing the page cannot block a tool forever.
A timeout is not a decision the human made, so the panel does not vanish the instant it fires: it stays for about five seconds with the countdown chip switched to "timed out; the host is settling it", the buttons and inputs greyed out, and a "settled by the timeout policy" note — then it closes. (Otherwise the card would simply disappear mid-read.) The three buttons the human presses himself close it immediately.
| Human action | Does the tool run? | What the agent receives |
|---|---|---|
| 【Approve】 | yes | the tool's normal result |
【Modify】 after an edit (default modify.mode: revise-request) | no | Error: HITL: the user revised the proposal, so tool "x" did not run. Re-issue the call with these changes: + - param: new text for every changed field |
【Modify】 after an edit (allow-and-inform) | yes | the tool's normal result + one user message right after it carrying the edited text |
| 【Reject】 | no | Error: HITL: the user rejected tool "x" and it did not run.; with feedback filled in, … User feedback: <verbatim> |
| timeout / no decision surface / request withdrawn | see §4 and §7 | the matching copy above; a withdrawal returns DSH's canonical cancellation result |
Why doesn't 【Modify】 run the tool by default? DSH does not allow rewriting a tool's arguments before execution (
PreToolDecisiononly hasallow/deny/cancel/ask; argument rewriting is still a proposed design). So "modify" hands the edited text back to the agent, which re-issues the call with the new arguments — the most honest semantics, and the session log stays consistent. If your tool can accept "run now, be told about the edit later" (a recorder or a display, say), setmodify.modetoallow-and-inform.
Every rejection/revision message carries the HITL: prefix so it is easy to find in a session log; the error codes are
HITL_REJECTED / HITL_REVISED / HITL_TIMEOUT / HITL_UNAVAILABLE.
prefers-reduced-motion), muted while paused, error-coloured with "timed out; the host is settling it" past zero. Buttons disable at zero, the panel lingers about five seconds with the "settled by the timeout policy" note, and only then closes.liveRequest() projection plus a client that re-bases its clock on every delivery). Refreshing the page, switching tabs, or hot-reloading therefore cannot make the countdown "jump back to full", and "the panel says ten seconds left while the host already timed out" cannot happen.holdGraceMs (5 minutes by default). After a reconnect the host's snapshot carries countdown.held, so a refresh cannot leave the panel pretending to be paused.preview / edit / side by side views. The renderer is a controlled subset written into this plugin (headings, lists, quotes, fenced code, inline code, bold/italic, links, rules, paragraphs) and inline code follows the host's markdown tokens (--dsw-alias-markdown-inline-code, --ds-font-family-code). Text is escaped before any tag is emitted, so HTML in the source stays text, and link targets such as javascript: are dropped.isFieldEditable) — a field may claim editable: true, but while the panel is submitting, settled, or expired the render area is already a read-only block and the label says "read-only" with it. "Labelled editable but impossible to click" cannot happen.editable: false never renders as an input — text becomes a block, and markdown keeps preview / side by side with no edit tab, labelled "read-only". The source pane in side-by-side mode is a selectable, copyable block, not a disabled input: text inside a disabled field can be neither selected nor copied in a browser, which would display content while refusing to hand it over.--dsw-alias-code-diff-added / -deleted colouring. When either side exceeds 2000 lines the panel stops aligning and shows both texts side by side with a note.--dsw-alias-*), so it follows light and dark themes; the panel carries an error boundary and degrades to one line of text instead of blanking the composer.A tool called by a subagent is gated too (in-process subagents, see the table below), but that call happens inside a child Session, and a pending decision in DSH is owned per Session: a panel only takes over the composer of the Session you are looking at. The plugin therefore files every request twice:
| Filing | What it buys |
|---|---|
| The Session that made the call | Open that subagent's conversation (the subagent card in the transcript → open the child Session) and the panel is there |
| The Session you are looking at | Wherever you are, the panel appears in the composer in front of you, labelled 「from subagent Session 4e05464d5b7d」 with an 【Open that Session】 button |
approval / plan-review / question, and this plugin's kind: 'hitl' is not one of them — so the child Session's row shows no dot at all. Without the follow-along filing, a subagent's gated call would silently wait until its countdown ends.Does it trigger? (measured once on a local profile)
| Subagent backend | Gated? | Why |
|---|---|---|
spawn / fork (in-process, subagent-in-process-driver) | yes | The child is another agent in the same process and the tool pipeline is the same one; this plugin's listener carries no agent scope tag, and scopeTarget admits untagged listeners for every key |
dsh-sdk / acp / claude-code / codex (out of process) | no | Their tool calls happen in another process and never pass DSH's tools/pre-execute; only the subagent call itself can be gated |
What happens after it triggers: the subagent's call stops at the gate (its turn stalls), the parent's subagent call is waiting on the child, and the whole conversation stalls with it — what you see on screen may just be "the subagent is still running". You can answer in the panel while you wait; on timeout the mount's countdown.action settles it and the child receives a tool error shaped like
Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. Tell the user this call timed out and wait for an explicit instruction before running it.
(with error: {name:'HitlTimeout', code:'HITL_TIMEOUT'} on the event), after which the child decides whether to report or retry. Give any mount a subagent might call a countdown: without one it waits forever, and nobody is there to click for the child.
shell.overlay)The composer seat is not the same thing as "visible": with no Session selected, with another main panel (the plugins page) on screen, with a higher-precedence shipped panel holding the composer, or covered by a full-viewport modal (the settings dialog is one — a body portal at z-index:1000 that leaves the conversation and its composer mounted behind it) — each of those needs another landing spo