dsh-always-queue
v0.2.3
Published
Single-session gate for the DeepSeek Harness web GUI: messages sent to an idle session while any other session is in progress are held in a cross-session queue and released FIFO only after the running session completes or pauses — no send-now for held mes
Readme
dsh-always-queue
Single-session gate for the DeepSeek Harness web GUI.
When your local LLM runs with parallelism 1, you want exactly one session in progress at a time — but the harness, by default, starts a new session immediately the moment you send it its first message. This plugin closes that gap entirely on the client side (no dsh source changes, no PR required):
- You start a new session (fresh context), click send → the message is queued (held) and does not start. It starts only after the running session completes or pauses (cancel works too).
- You start yet another new session and send → it is queued behind the first.
- You go back to an old (idle) session and send while another session is running → that old session is queued as well.
- Held messages are released FIFO per session: the oldest waiting session starts first; its own follow-up messages join its normal per-session queue. The next session starts only after that one completes or pauses.
- There is no "send now" / steer affordance for held messages — that is the point of the plugin. The only exits are remove and pull back to the composer (text entries, empty draft).
- Within the running session, nothing changes: the default in-session message queueing and steer (send-now) behavior is preserved exactly. This plugin never shadows the official queue dock or the busy-Enter preference.
How it works
- The client plugin wraps the
conversationservice'ssend/sendSessionverbs at the instance level: the raw service instance is read through cordis's traceable proxy (thecordis.originalsymbol), and the two verbs are replaced with gated wrappers on the instance itself. That intercepts every read path — bothctx.get('conversation')(what the composer's send sink uses) andctx.conversationproperty access — with the per-caller context rebinding intact. (Theinternal/getwaterfall only fires on property access, so a waterfall listener would miss the composer sink, which reads the store directly.) A send addressed to a session that is not itself running is held while any other session counts as busy. - A claim marks a session busy from the moment its turn starts — either
a pass-through send or a plugin release — until the host's
runningflag lands in the list snapshot (a 15 s safety valve covers a turn that ends before its flag lands). This closes the race where a second session could start in the gap before the host confirms the first one is running. Release liveness is closed off: a check that lands while a release is in flight re-runs after it settles, and a sweep timer re-checks when the oldest claim expires — a lost status frame or a quiet host can never wedge the queue. - A held message (text plus any attached images, captured as base64) lands
in a per-page queue persisted to
localStorage, so it survives a reload. - A held first send also engages its session locally (the harness's own
first-send flip:
promptAttempted/blankBit/onEngaged), so the list row stops reporting blank. Without this, the New Session flow would keep reusing the still-blank session and a second new session would be uncreatable while the first one's message waits. The same hold assigns the session its provisional title viarename— the deterministic first-five-words fallback, byte-identical to the title the host would fold when the message releases — so the sidebar shows it like the normal flow. The flip is re-asserted on every list change: the harness applies it as a one-shot list mutation that a full host re-fetch (including this plugin's own stuck-gate watchdog re-pull) discards, re-blanking the row and letting the New Session reuse scan silently reuse the held session. Engaged session ids are tracked and flipped back until the session's host-side un-blank sticks (its first turn lands). - A release loop subscribes to the session-list snapshot: when no session
reports busy (flag or claim), the oldest waiting session's whole batch is
delivered through
session.promptin FIFO order. Failed deliveries are never dropped silently: the message stays at the head of the queue with capped exponential backoff (2 s to 60 s), the session's composer gets an error notice, and every attempt is traced to the console. The only drops are the unrecoverable ones (the target session no longer exists on the host), and even those are announced viaconsole.errorplus a composer notice. A 5 s release poller re-runs that check on a fixed cadence while anything is held, independently of list events — so a completed session's queued successor starts within ~5 s even if its status frame was lost, the list subscription died, or no further event ever arrives (the "queue pops within 15 s of completion" guarantee). And a release whosesession.promptRPC never settles (dead connection) would otherwise pin thereleasingflag forever and wedge the queue with no spinner anywhere; a stuck-release watchdog force-resets it after 30 s (a hung delivery may be resent — a duplicate is preferable to a silently lost message). - A stuck-gate watchdog: if the gate stays closed with a non-empty
queue for 60 s, the page re-pulls the host session list (read-only; the
gate is never forced open). A client-side
runningflag that missed its status frame (lost frame, quiet host) self-heals against the host truth within a minute instead of holding the queue forever. - Diagnostics: every gate decision, claim transition, release attempt
and queue mutation is traced to the browser console under the
[dsh-always-queue]prefix, plus a 5 s state heartbeat while anything is held (phase/verdict/pending/busy(hostflagvs pluginclaim) /claims/inFlight/releasing/gateClosedForMs). Theverdictfield states exactly what the next check will do (GATE OPEN — will release on next poll,hold — gate closed by [...],release in flight (N ms), orSTUCK release — force-reset on next poll). That is the debug surface for "my queued session never starts": the busy rows name the exact session(s) holding the gate. - An additional
conversation.input.dockentry (idalways-queue, rendered below the official queue strip) shows the held messages of the session you are viewing, with a pulsing "waiting" banner. While a session holds the gate, the banner names it ("waiting for X to finish or pause"), so a held queue is never a mystery.
Compatibility
Verified end-to-end against 0.1.2-rc.1 (installed runtime): gate wiring,
FIFO hold/release, cross-session reuse protection, provisional titles,
the engagement re-assertion across host list re-fetches (the one case where
0.1.2-rc.1's refreshList drops the harness's one-shot engaged
mutation), and the stuck-release recovery (a prompt RPC that never settles
force-resets on the poller and the held message still delivers).
Install
From a directory (development):
npx @deepseek-ai/dsh plugin --profile web add ./path/to/always-queue -wAfter publishing to GitHub:
npx @deepseek-ai/dsh plugin --profile web add <your-username>/dsh-always-queue -wThen restart the web profile (dsh web). The plugin is a profile layer:
removing it is npx @deepseek-ai/dsh plugin --profile web remove dsh-always-queue -w.
What it does NOT change
- The official per-session queue dock (in-session message queue) and its editing/steer controls — untouched.
- The busy-Enter (queue vs steer) preference — untouched.
- Sessions while no other session is running: the very first send still starts immediately, exactly like the default.
- Slash commands are not gated (only plain message sends go through the composer sink this plugin wraps).
Troubleshooting a stuck queue
- Open the browser DevTools console on the GUI page and filter for
dsh-always-queue. While anything is held, a 5 s heartbeat prints the whole gate state including averdict(what the next check will do); thebusylist names the session(s) holding the gate and whether each hold is a hostrunningflag or a plugin claim. - If a
flagkeeps holding the gate while that session is visibly idle, the watchdog re-pulls the host list after 60 s (log:stuck-gate: ... re-pulling host list); a followingrelease: start batchmeans the queue recovered on its own. A queue that sits still with no busy session andverdict: GATE OPEN — will release on next pollshould be impossible — report it, that is a plugin bug. - A
release: STUCK — prompt RPC in flight ...line means the connection died mid-delivery; the next poll force-resets the release and the held message goes out (a hung delivery may be resent once — a duplicate beats a lost message). - Repeated
release: FAILEDlines mean the host is rejecting the prompt RPC; theerrorJSON names the code (e.g.model-unavailablewhen no adapter serves the session's model). The message stays queued and keeps retrying. dock: user removed/pulled back entrylines rule out manual removal.
Limitations
- Client-side by design: the gate lives in the browser page. Sending to the same session from a second client (e.g. the TUI) is not gated.
- Single page/tab is assumed (the queue is per-page, persisted to that browser's localStorage).
- Image attachments on held messages are captured as base64 at hold time; very large image sets are bounded by the localStorage quota (5 MB) — if the quota is hit, persistence degrades to in-memory for that page.
- The provisional title rides the host rename channel, so it counts as a user title (pinned): a later automatic title revision (e.g. an LLM refinement) will not replace it. The text assigned is exactly the fallback the automatic path would produce, and a manual rename supersedes it at any time.
- The engagement flip touches harness-internal session fields (best effort). If a future harness release renames them, holds still work; only the second-New-Session and provisional-title conveniences degrade.
Build / verify
npm install
npm run verify # lint + tests + build + assembly verificationArtifacts: lib/index.js (node half, no-op) and lib/client.js (browser
half, served by the web profile at /plugins/dsh-always-queue/client.js).
examples/verify-assembly.mjs re-checks the loader surfaces from a profile
node_modules copy.
License
MIT
