dn4hjtcr9s-del/dsh-wait-guard

dsh-wait-guard

Keep an Agent's turn open until every descendant subagent settles — a pure Host-plane completion gate for DeepSeek Harness.

Uncategorizeddeepseek-harnessdshmulti-agent

dsh-wait-guard

A completion gate for DeepSeek Harness agents: as long as any subagent an Agent dispatched is still running, that Agent's turn is not allowed to end.

It is a pure Host-only plugin — no tools registered, no settings written, no files touched, no global state. Remove the bundle and everything is exactly as it was.

中文说明


Install

Straight from GitHub (no npm account needed; verified in an isolated profile):

# track main
dsh plugin --profile <your-profile> add github:dn4hjtcr9s-del/dsh-wait-guard

# pin a version (reproducible)
dsh plugin --profile <your-profile> add github:dn4hjtcr9s-del/dsh-wait-guard#v0.1.0

From npm, once published — shorter:

dsh plugin --profile <your-profile> add dsh-wait-guard

Or, in the Web UI: Plugins → Add plugin → paste whatever follows add above. Installing a bundle activates through HMR; replacing an already-installed package needs a restart to load a fresh module generation.

Uninstall: remove the bundle from the same page, or dsh plugin --profile <your-profile> remove dsh-wait-guard.


What it guarantees

✅ GuaranteedA delegating Agent's turn cannot end while any descendant is still running — including the case where an intermediate agent is idle between turns but its own children are running
✅ GuaranteedAny arrival ends the pause: a human message, a peer relay, or a runtime settlement notice. Deactivation means the plugin leaves completely — it stops waiting and injects nothing, so that step belongs entirely to the model
✅ GuaranteedA deactivation is always temporary: the next time the model tries to close the turn, the gate re-engages immediately if descendants remain. There are exactly two ways to deactivate: a message arrived or the silence timer elapsed
❌ Not guaranteedThat the model produces no intermediate prose — the gate governs the turn, not speech
❌ Not blockedThe model interrupting its own subagents (interrupt_agent); that is its own judgement, and the interruption produces a settlement notice anyway
⚙️ OptionalSet releaseOn: ['user','agent-message'] to restore the old "accumulate every settlement notice, then hand them over in one batch" behaviour

Delivery is never gated by this plugin: messages are spliced into the Agent inbox by the runtime. A subagent messaging a different agent does not pass through here at all.


Configuration

The bundle's cordis.patch.yml inserts one row; configuration lives on that row.

FieldDefaultMeaning
firstWaitMs60000How much silence means "time to remind". Each attempt to close the turn is timed separately: when it elapses, the plugin injects exactly one notice and leaves immediately
nudgetrueEnable the timer path. false makes the gate completely silent (it only waits)
pollMs250Poll interval. Elapsed wait is measured on the wall clock, so probe latency cannot skew it
releaseOn['*']'*' = any arrival deactivates the plugin. ['user','agent-message'] makes settlement notices accumulate instead
companionReminderfalseOff by default: an arrival deactivates the plugin with nothing added. Set true to also inject "this is progress, not the final answer" when a next-step arrival is what ended the pause
maxCompanionPerTurn2Per-turn cap for that companion reminder
policySectiontrueInject a short rule into the system prompt telling the model to wait for every dispatched subagent
onProbeError'open'When the descendant listing cannot be read: open releases the turn, closed keeps waiting
policyText / nudgeText / companionTextbuilt inOverride the wording. Templates support {n} (pending count), {s} (seconds waited), {list} (pending children)
debugfalseDiagnostic logging

Example override (in a profile patch, a bundle patch, or your own workspace bundle):

- id: wait-guard
  config:
    firstWaitMs: 120000
    nudge: false

A row patch replaces config wholesale — restate every field you still want.


How it works

The gate hangs off agent/turn-stopping, the event dsh-agent-loop awaits immediately before it re-reads the inbox and decides whether to close the turn:

// dsh-agent-loop: the two checks around the awaited event
if (turnEnds && this.inbox.nextStep.length === 0) {
    await this.dispatch.serial("agent/turn-stopping", { turn, signal });  // ← the gate pauses here
}
if (turnEnds && this.inbox.nextStep.length === 0) break;                  // ← the turn really ends

The dispatch is awaited and serial, its return value is discarded, and turnEnds is not writable from a listener — so the only lever a listener has is the inbox. This plugin simply does not return while work is pending, and then releases; anything it injected keeps the turn alive for one more step.

Inside that await it polls the whole descendant tree (listDescendants, filtered by the Agent registry's real status), and exits on: an arrival, the silence timer, a quiet subtree, cancellation (signal.aborted), or plugin disposal. Pending waits are resolved on disposal, so unloading the plugin can never leave a turn stuck.


Verification

npm test        # or: node test/self-test.mjs

22 tests drive the real plugin against a fake host context: no pending work, single-level and multi-level (idle coordinator with a running grandchild) holds, arrival deactivates and injects nothing, deactivation is temporary, opt-in companion, opt-in batching, wall-clock nudge timing under slow probes, one notice per stop attempt, nudge:false silence, abort, disposal, fail-open/fail-closed probes, session-format-V4 source admission for every emitted notice, conditional policy section, config fallbacks, and message-id/summary bounds.

Every live behaviour above was also reproduced against a real Harness profile: the gate held a premature answer for 11–61 seconds until the last subagent settled, and the recorded turn/end count stayed at zero throughout.


Development notes

  • Host-only bundle: no dependencies, no build step, no client entry — the implementation is a single import-free module, so nothing resolves at load time.
  • Editing the code while the app runs: configuration hot-reloads, but module files do not (dsh-hmr ships with root: []), and the loader reuses the runtime already imported for a row id. Either restart, or add a never-imported entry file plus a new row id and bump the query on its internal import. Do not put a query string in a row's name: the loader treats it as a filesystem path and the import silently fails.
  • Tested against DSH 0.1.7-rc.2 (macOS desktop) and 0.1.5-rc.2 (npm CLI).

Known limits

  1. A subagent that never settles means the Agent waits. Reasons to exit: the timer notice gives the model a chance to interrupt_agent, the user can stop the turn, or set nudge: false for a silent gate.
  2. Each arrival costs one model request (that is the price of "handle the message now"). Set releaseOn: ['user','agent-message'] to batch settlements instead.
  3. The gate never rewrites the reason a turn ended — it only postpones the decision.
  4. Non-continuable one-shot subagents are not covered: they cannot be resumed and never send settlement notices, so they are invisible to the listing this plugin watches.

License

MIT