YunpengDon/dsh-hitl

dsh-hitl

Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI.

未分類ai-agentapprovaldeepseek-harness

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.

View Website

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:

  1. 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.
  2. Either list the tools you want to protect in the 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).


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)
}

owner decides whose account this mount's revocation is filed under. It is the calling plugin's Cordis context — normally the ctx your apply received: pass it and hitl files 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 the hitl plugin, not by yours — so without an owner the disposer protect() returns is the only way to unbind, and "return a function from apply" 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 at ctx.hitl.list() and clear it with ctx.hitl.unprotect('bash'), or toggle the hitl row off and on in the plugins page (which rebuilds the plugin's state).

2.3 Service API

MemberPurpose
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)

owner is optional in the signature because there is a second entry point: the rows in config.protect belong to the hitl plugin itself (they are cleared when that row unloads) and need no owner. On the ctx.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).


3. Default proposal rules

Without fields, the proposal is every parameter of this call:

  • field title = the parameter key (or the schema's title when 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 / text are editable by default; diff / json / hidden are 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)

KeyTypeDefaultPurpose
tool / matcherstring | RegExp | array | functionrequiredThe tools to protect
titlestringsee §3Panel title (bold, wraps)
layoutstacked | splitstackedProposal layout: stacked vertically / side by side
labelsstring[][]Panel-level labels (zero or more)
buttons{approve?, modify?, reject?}follows the languageButton copy overrides
fieldsFieldSpec[]all parametersWhich parameters to show, and how
diff{path?, before, after}noneFold two parameters into one read-only side-by-side diff
countdown{seconds, action, freezeOnInteract?} | null | falsenullCountdown and timeout behaviour; null/false = no deadline
reject.feedbackbooleanfalseWhether to show the rejection feedback box
reject.feedbackPromptstringbuilt-in copyFeedback box placeholder
reject.requireFeedbackbooleanfalseFeedback is required (an empty box is refused with a hint)
modify.moderevise-request | allow-and-informrevise-requestWhat 【Modify】 means after an edit; see §5
whenUnavailablereject | waitrejectBehaviour 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
enabledboolean | (exec) => booleantrueTurn a mount off temporarily / make it conditional
maxFieldCharsnumber20000Per-field render cap; longer values are truncated with a note
resolveTimeoutMsnumber5000Bound 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 fields is 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.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.
  • 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:

actionDoes the tool run?What the agent sees
approveyesnothing extra (the tool returns normally)
rejectnoError: HITL: no human decision arrived within Ns, so tool "x" did not run (timeout policy: reject).
notifynoError: 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 actionDoes the tool run?What the agent receives
【Approve】yesthe tool's normal result
【Modify】 after an edit (default modify.mode: revise-request)noError: 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)yesthe tool's normal result + one user message right after it carrying the edited text
【Reject】noError: HITL: the user rejected tool "x" and it did not run.; with feedback filled in, … User feedback: <verbatim>
timeout / no decision surface / request withdrawnsee §4 and §7the 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 (PreToolDecision only has allow / 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), set modify.mode to allow-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 carries countdown.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 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.
  • 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 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.
  • Read-only fields: a field with 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.
  • 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 / -deleted colouring. 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:

FilingWhat it buys
The Session that made the callOpen that subagent's conversation (the subagent card in the transcript → open the child Session) and the panel is there
The Session you are looking atWherever 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'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 backendGated?Why
spawn / fork (in-process, subagent-in-process-driver)yesThe 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)noTheir 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 spo