dsh-hitl · Human-in-the-loop for DeepSeek Harness
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.
Features
- Mount with one line: another tool or plugin puts its own tools behind HITL with one API call or one config entry;
- A decision card before execution: before the tool really runs, the DSH Web UI shows a title + the pending proposal + decision buttons;
- Several ways to render a proposal: side-by-side diff (read-only), plain text input, editable and rendered markdown input — and text and markdown fields can each be presented read-only or editable;
- Modify mode: once you edit proposal text, the primary button turns from 【Approve】 into 【Modify】 — your edit goes back to the agent as a revision request;
- 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";
- Configurable countdown: a countdown plus three endings (approve automatically / reject automatically / report the timeout to the agent).
Made for DSH
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 →
1. Install
# 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:
- Refresh the page once (important). The plugin's token reaches the page through the index injection (
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. - Either list the tools you want to protect in the
hitlrow ofcordis.patch.yml(next section), or let other plugins callctx.hitl.protect(...).
Uninstall:
dsh plugin --profile <profile> remove dsh-hitl(its configuration layer is removed with it).
2. Thirty-second start
2.1 Option 1: through configuration (the target tool keeps its code)
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.
2.2 Option 2: mount HITL from the target plugin's own code
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)
}
Always pass
owner(the third argument). Mounts are state owned by thehitlplugin, not by yours. 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. Withctx,hitlregisters an effect on your plugin's context and the mount is released when your plugin unloads. 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).
2.3 Service API
| Member | Purpose |
|---|---|
protect(matcher, options?, owner?) |
Mount one tool; pass the caller's ctx as owner for automatic unbinding; returns a disposer |
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) |
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).
3. Default proposal rules
Without fields, the proposal is every parameter of this call:
- field title = the parameter key (or the schema's
titlewhen the tool schema provides one); - field value = the parameter value, rendered as a markdown input in every case (non-string values arrive as pretty-printed JSON text);
- editability:
markdown/textare editable by default;diff/json/hiddenare always read-only; - when the arguments are an empty or non-object value (a single scalar parameter, say), the panel shows one read-only
(arguments)field; - the panel title defaults to
Confirm how <toolName> runs(and follows the interface language).
4. Mount options (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 |
FieldSpec
{ 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'
- With no
render, a field renders as a diff when one can be configured, and as a markdown input otherwise. - When
fieldsis given, only the listed fields are shown (parameters you leave out never reach the panel). - Config rows may shorten a spec to a bare string:
fields: ['command', { param: 'cwd', title: 'Working directory' }].
Computed fields: putting what the arguments do not carry on the card
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}` } },
],
})
- A string
diff.paththat 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. - Resolvers may be async; one field's resolution is bounded by
resolveTimeoutMs(5s by default). - 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. - The wire format and the browser half are unchanged: both sides of a diff always travelled in the frame; the panel just renders them.
- Each side of a diff is cut past
maxDiffLines(2000 lines) with a note, so a whole-file diff cannot become an unbounded frame.
The three endings of a countdown
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.
5. Decision semantics (what you press, what the agent receives)
| 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.
6. Panel behaviour
- Where it lands: the composer seat of the Session — the same place DSH's own approval and question panels use, which is where "the agent is stuck waiting for you" already lives.
- Collapse: the hide/show control beside the title folds the whole proposal away, leaving the title, the countdown, and the decision buttons (the glyph follows the direction it will move: ∨ when expanded, ∧ when collapsed), so the panel never hides the conversation above it. The preference is remembered: collapse once and later requests start collapsed. Pressing 【Reject】 while collapsed on a mount with a feedback box expands the panel first and focuses the box, so your words are never lost.
- Priority: a HITL decision outranks the approval and question panels; when one Session has several pending decisions, the earliest is shown, and the panel notes "N more waiting" in the corner.
- Countdown: it sits at the left of the action row (the same row as 【Reject】【Approve】, pushed fully left) as a deliberate chip rather than faint grey text: solid and bold with tabular figures normally, warning colour with a slight pulse under ten seconds (honouring
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. - The countdown is always the host's number: every frame (a new request, a reconnect snapshot, a resend after a client hot-reload) carries the remainder the host computes at that moment (the host half's
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. - Interacting pauses it: focusing or editing any input asks the host to freeze the countdown, and only shows "paused while you edit" once the host agrees — the label agrees with the host's clock and the remainder really stops. Blurring resumes it, bounded by
holdGraceMs(5 minutes by default). After a reconnect the host's snapshot carriescountdown.held, so a refresh cannot leave the panel pretending to be paused. - Markdown fields: they open on the rendered preview (a decision panel is read before it is edited; the source is second-class information), with
preview / edit / side by sideviews. 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 asjavascript:are dropped. - The field header states what the control can do right now: the small label beside a field title reads "editable / read-only" and comes from the same predicate the render area uses (
isFieldEditable) — a field may claimeditable: 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. - Read-only fields: a field with
editable: falsenever renders as an input —textbecomes a block, andmarkdownkeepspreview / side by sidewith no edit tab, labelled "read-only". The source pane in side-by-side mode is a selectable, copyable block, not adisabledinput: text inside a disabled field can be neither selected nor copied in a browser, which would display content while refusing to hand it over. - Inputs grow with their content: a single line occupies one line of height, the box grows as you type, and past 320px it scrolls internally instead of filling the panel with blank space. The growth happens before paint (a layout effect); doing it after would expose one frame of a one-line box, shrink the proposal area, and let the browser clamp the scroll position back to the top.
- Switching views keeps your place: 【Edit】/【Preview】/【Side by side】 record the proposal's scroll anchor before the switch and restore it before paint, so a long proposal does not jump back to the top.
- Diff fields: read-only, two panes, aligned line numbers, with
--dsw-alias-code-diff-added/-deletedcolouring. When either side exceeds 2000 lines the panel stops aligning and shows both texts side by side with a note. - Several windows: every open tab receives the same request; the first decision wins, and the other tabs drop the panel when the settled frame arrives.
- Refresh / reconnect: the host sends one snapshot on reconnect, so pending decisions come back and nothing is lost.
- Styling: only DSH theme tokens (
--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.
6.1 Where a subagent call lands
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 |
- Both filings are one request: deciding in either place settles the one call, first answer wins, and the panel's "N more waiting" counts every pending request.
- Switch Sessions and the follow-along filing moves with you (the client subscribes to the host's main-view binding source); no page refresh needed.
- Why this is necessary: the status dot on a session row only knows the built-in kinds
approval / plan-review / question, and this plugin'skind: '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.
6.2 Frame-level fallback notice (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 spot. So the plugin registers one more root-scoped overlay (dsh-hitl.notice in shell.overlay, registered additively, replacing nothing):
- It appears when there is a decidable request and (there is no composer seat or the seat's panel can no longer be reached). "Cannot be reached" is measured by the panel itself once a second with a hit test (
document.elementFromPointlanding outside the panel = covered, clipped, or scrolled out of view) rather than guessed from Session state; the moment the panel is really in front of you, the notice yields — the two never show the same request twice. - Visible even when covered: the notice promotes itself to the browser's top layer with
popover="manual"+showPopover(), so even az-index:1000modal cannot hide it (where the API is missing it degrades to fixed positioning inside the shell overlay, and a modal can cover it again). - It shows the count, the tool name, the source (this Session / a subagent Session) and the host's countdown; at most three rows, the rest folded into "N more".
- It offers the two decisions that need no editing — 【Approve】【Reject】 — plus 【Open that Session】. Modify and reject-with-a-reason stay in the Session panel.
- It wraps itself in an error boundary and renders null on failure: a surface painted over the whole frame must be able to disappear, not leave an empty box behind.
6.3 Interface language
The panel follows the application's current language — not the mounting plugin's, and not a hard-coded Chinese:
- Where the language comes from: first the user's choice in Settings → General → Language (persisted in DSH settings, so it survives a different browser); with no choice, the browser decides — the first tag in
navigator.languagesthat matches a registered language (an exact id first, then the primary subtag, sozh-CN,zh-TW, andzh-Hansall resolve tozh); with no match, English (DSH'sFALLBACK_LOCALE, on the grounds that a reader who named no registered language is the least likely to read Chinese). In short: a Chinese browser gets a Chinese panel; every other language (Japanese, German, French, …) gets English. - Per-key fallback chain: the active language's dictionary → each language's declared
fallback(language packs) →en→ the shared namespace → and only then the key itself. This plugin'szhandendictionaries carry identical key sets (locked by a test), so a key can never leak; even with a third language pack installed (a Japanese UI, say), this plugin's copy lands on English rather than printinglabel.countdown. - Switching language takes effect immediately, no page refresh (the slot locale binding and dictionary registration both advance the revision, and rendered seats re-read their copy).
- The plugin list's name and description travel a different DSH path:
locale/<language>.jsoninside the package, shaped{"meta":{"title":…,"description":…}}, with./locale/*.jsoninexportsandlocaleinfiles. Miss any of those and the card title silently falls back to the package namedsh-hitl(a test in this repository watches for exactly that). - What is never localized:
protect()'stitle, itslabels, and every fieldtitle/description(that is the mounting plugin's own copy), the tool name, and the raw argument values in a proposal. Bilingual titles would have to be passed as separate strings per language by the mounting plugin. - Host-half text aimed at the model is always English (rejection reasons, timeout notices, revision notes), matching every other DSH pre-execution policy — it enters the model's context, not a human's interface.
7. Security model and fail-closed
- Panel and host talk over this plugin's own same-origin HTTP channel:
GET <endpoint>/events(SSE downlink, delivering a snapshot on connect) +POST <endpoint>/decide(JSON uplink). The defaultendpointis/dsh-hitl. - The channel is protected by one random token per process, delivered through DSH's structured index-injection row (
webserver/index-inject,{kind:'global'}) into the page'swindow.__DSH_HITL__; it is never logged and never written to disk. - The server also checks that the request comes from a loopback address, that a POST carries
content-type: application/jsonand (when present) anOriginmatching the Host, that the body stays under 256 KiB, and compares tokens withtimingSafeEqual. Those checks stop other web pages on the machine (CSRF / DNS rebinding), not local processes — a local process can read~/.dshanyway. - 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).SetwhenUnavailabletowaitif it should wait forever in unattended runs instead. - 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.
7.1 The security boundary: what HITL stops and what it cannot
- It stops a call from starting; it does not stop the world. If a tool has already produced a side effect inside its own execution layer (a concurrent race, an asynchronous write, a request already sent), a rejection prevents "next", never "already" — HITL is neither a transaction nor a sandbox.
- So how safe you actually are depends on two things: ① how dangerous the operations your matcher covers are; ② whether the tool
puts its irreversible step last. Mounting
bash/write/edit— tools that are dangerous the moment they start — is the right use. If a tool only reveals its risk halfway through, HITL can only help you decide whether to begin. - A human's approval is not a privilege escalation: the sandbox, guards, hooks and approval still run afterwards (§8); and the
reverse holds too — another pre-execution policy can refuse before this plugin is even asked. That was measured once:
fs-observation-policystopped a call before the human pressed 【Approve】, so no HITL panel ever appeared (§8). whenUnavailable: waitis fail-open: with no browser connected the call simply waits, and without acountdownit waits until its Session ends. Use the fail-closed default (reject) when an unattended run must not stall, or pairwaitwith acountdown(the host warns at mount time when you do not).- The local trust boundary: the decision channel and
/statusanswer loopback addresses only. Any process on this machine can read/status, which carries the mount list and, per pending decision, the tool name / Session id / remaining milliseconds / hold state — no tool arguments and no token. To size that hole precisely: a local process can already read the session logs under~/.dsh/sessions/**, which do contain every tool argument, so/statusexposes a strictly smaller subset of what is already readable locally. The hole is deliberate: without it, "what is HITL holding right now?" could only be answered by opening the page.
8. Relationship to other pre-execution policies
- This plugin listens on
tools/pre-executewithprepend: 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. - So "the human approved but the sandbox or a guard still refused" is by design, not a bug.
- The reverse holds too: another pre-execution policy can refuse before this plugin is ever asked, in which case no HITL panel
appears at all — so "it ran (or failed) without asking me" is a question for the other policies first. Measured once:
fs-observation-policydemands a re-read before overwriting a deleted file, and it stopped a call both after the human pressed 【Approve】 (the panel had appeared as usual) and, in another round, before this plugin was reached (no panel at all). - This plugin only asks before execution: it never rewrites a tool's arguments and never rewrites a tool's result.
9. Known limitations
- Tool arguments cannot be rewritten (see §5), so 【Modify】 defaults to "refuse and hand the edit back".
- 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.
- 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). - 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). - Only the Web UI has a panel: under ACP/TUI/headless the mount's
whenUnavailabledecides (reject by default). - Only a same-origin Web deployment (page and plugin routes on the same
dsh webaddress); the Desktop worker form is unverified. - The user message under
allow-and-informis built by hand in the shape ofcreateUserMessage({id, role:'user', source:{kind:'user'}, content}), because a plain-JS plugin cannot import host factories. - The panel registers no global shortcuts (so it never steals the host's Enter/Esc); click the buttons.
- Out-of-process subagents are outside this plugin's reach (see §6.1): tool calls inside the
dsh-sdk/acp/claude-code/codexbackends never passtools/pre-execute; only thesubagentcall itself can be gated. - The frame-level notice is not the full panel: it offers 【Approve】/【Reject】 only (without a reason); modify and reject-with-a-reason need the Session panel.
- A subagent request makes the parent wait too: nothing this plugin can change — the parent is waiting for the child, and the child is waiting for your decision.
10. Troubleshooting
| Symptom | Cause / what to do |
|---|---|
The tool is rejected outright with no browser is connected to decide |
The page is not on the decision channel: refresh the page once after installing, and confirm the browser console shows no 401 |
| The panel never appears and the tool just hangs | The request belongs to a Session you are not looking at. The plugin also files it under the Session you are looking at (§6.1), so this should not happen; if it does, look for the frame-level notice (§6.2) or switch to the asking Session |
| A subagent's call is left hanging, or a subagent stalls the whole conversation | Expected: an in-process subagent's call really is gated, and the parent waits with it (§6.1). Give it a countdown, or answer in the panel/notice |
| A tool inside an out-of-process subagent is not gated at all | Expected (§6.1): those tools run in another process and never pass tools/pre-execute |
| No dot on the child Session's sidebar row | Expected: DSH's row status dot is a compile-time closed set (approval/plan-review/question) and this plugin's kind: 'hitl' is not in it. Use the composer panel or the frame-level notice |
countdown seems to do nothing |
Check that seconds is a positive integer and action is one of the three values; an invalid config logs a dsh-hitl: warning on the host and degrades to "no deadline" |
| A config change does nothing | A patch replaces the whole config of the row it overrides; confirm the id: hitl row still carries every key |
| The plugin is installed but the host half never starts | Check dsh's stderr: a row that fails to load does not exit dsh, and the log carries dsh-hitl: ... or the row id |
| Behaviour does not change after upgrading the plugin | Replacing an installed package needs a dsh restart (the plugin manager does not swap a new version in place) |
| Behaviour does not change after editing plugin code | The browser half (client.js) is picked up by client hot-reload; the host half (index.js, lib/) sits behind Node's ESM module cache, so disable → enable on that row does not reload the code — restart dsh |
| See the host state at a glance | curl -s http://127.0.0.1:3081/dsh-hitl/status (loopback only, no token, read-only): connected browsers, tokens issued by this process, mounted tools, and the pending list with live remaining milliseconds and hold state. It carries no token and no tool arguments |
Host-half diagnostics all carry the dsh-hitl: prefix (invalid mounts, dropped connections, refused channel requests).
Appendix · Protocol (if you want to write your own UI)
Downlink (SSE, data: <json>):
{ "type": "snapshot", "version": 1, "requests": [Request, ...] } // sent on connect
{ "type": "request", "version": 1, "request": Request } // a new pending decision
{ "type": "settled", "version": 1, "id", "outcome": "user|timeout|abort|host" }
{ "type": "holdAck", "version": 1, "id", "held": true, "expiresAt": 1712 }
Request:
{ "id", "sessionId", "toolName", "callId?", "title?", "layout", "labels": [],
"fields": [ { "param", "title", "description?", "render", "editable", "labels": [],
"value"?, "truncated"?, "diff"? { "path?", "oldText", "newText" } } ],
"buttons": { "approve"?, "modify"?, "reject"?, "feedback": { "enabled", "prompt"?, "required" } },
"countdown": { "remainingMs", "action", "freezeOnInteract" } | null,
"createdAt" }
Uplink (POST <endpoint>/decide, Authorization: Bearer <token>):
{ "type": "decide", "id", "decision": { "kind": "approve" } }
{ "type": "decide", "id", "decision": { "kind": "modify", "fields": [{ "param", "text" }] } }
{ "type": "decide", "id", "decision": { "kind": "reject", "feedback"?, } }
{ "type": "hold", "id", "held": true }
Reply: { ok: true, accepted: true, held?, expiresAt? } or
{ ok: false, code: "unauthorized|bad-payload|unknown-request|already-settled|host-gone", message }.
Appendix · File structure
dsh-hitl/
├── index.js 666 host half: service + gate + countdown/hold + SSE & decision routes + token injection
├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
│ ├── protocol.js 195 frame and uplink validation, limits, error codes (the single source of the protocol)
│ ├── resolve.js 363 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
│ ├── fields.js 449 default proposal derivation, diff pairing, computed-field resolution, decision → model-visible text
│ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
│ ├── en.json 6
│ └── zh.json 6
├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
├── tests/ zero-dependency Node tests, 167 cases in total
│ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
│ ├── fields.test.js 387 (37) proposal derivation, computed fields and decision text
│ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
│ ├── resolve.test.js 224 (22) matchers and option normalization
│ ├── protocol.test.js 117 (16) frame and uplink validation
│ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
│ └── manifest.test.js 77 (4) package metadata and the locale resource shape
├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
├── package.json 68 manifest: exports / dsh.bundle.patch / dsh.client / icon
├── cordis.patch.yml 17 the bundle's configuration layer (inserts the row with id `hitl`)
├── icon.svg 6 the plugin list icon
├── LICENSE MIT
├── README.md this document
└── README.zh.md the Chinese twin of this document
Which file to edit, and how a change takes effect (the same rule as the §10 table):
| File | Half | How a change takes effect |
|---|---|---|
index.js, lib/*.js |
host half (Node) | restart dsh. Node's ESM module cache is not invalidated by "disable → enable on that row" |
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 |
locale/*.json |
plugin-list metadata | when the plugin manager re-reads metadata (opening the plugins page / restarting dsh) |
docs/*.png |
screenshot sources | kept in the repository only; the published package excludes them (see the appendix note) |
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 |
tests/*.js |
development | never runs in production; executed by npm test / node --test |
Why it is shaped this way (three constraints worth reading before changing code):
client.jsmust be a self-contained classic script. DSH serves it verbatim as/plugins/dsh-hitl/client.js, its entry point is fixed atwindow.__ModuleLoader__.load({ id, factory(require) }), it may takereactfrom the module table, and it must not import any@deepseek-ai/*client package (no bundling step will resolve them). That is why the markdown renderer, the side-by-side diff, the countdown, and the panel styles all live in that one file, with CSS built from--dsw-*theme tokens only.lib/holds pure modules for the host half alone. The browser half cannot require them, and they pull in neither Cordis nor HTTP — which is what letsnode --testrun each of them directly, with protocol and state-machine behaviour that does not depend on the runtime.- The pure logic in
client.jsreaches the tests through the__testkey of the factory's return value:tests/client.test.jsloads the realclient.jsbehind a stubwindow.__ModuleLoader__and a stub React, so browser-half logic has regression cover too (markdown rendering, diffing, the clock, filing, and seat visibility are all in there).
Development and self-checks
node --check index.js client.js lib/*.js # syntax
npm test # 167 zero-dependency cases: pure functions, state machines, the channel
npm run check # both of the above (syntax + the whole suite)
What each file does, how long it is, and whether changing it needs a dsh restart live in the Appendix · File structure above (the listing is deliberately not repeated here, so the two descriptions cannot drift apart).
The contract between this plugin's host half and browser half is defined by lib/protocol.js + tests/protocol.test.js.
No comments yet. Be the first to write one.