dsh-supervision
Bridge a running DSH session into any terminal supervisor — it sees the state, and it can steer. The plugin mounts inside the dsh web process you already run: OSC 7501 records go out on the terminal, and operator input comes back into that same session. herdr-core is one supported supervisor; any supervisor that reads OSC 7501 and can write lines back can drive a bridged session, with no changes to DSH or the supervisor.
What you get
- One status record per session. The bridged session is the root record —
idle,working,done,blockedorerror; other sessions in the same process are child records carryingid=. - Operator input goes back in. Bare text steers a running turn,
/cancelends it,/status,/newand/attachinspect and switch sessions, and/approveor/rejectsettle parked asks — all from the terminal. - Approvals stop living in another surface. A parked ask turns the root record into
blocked · kind=permission, is settled from the terminal, and the GUI shows a supervision banner with Take back control — so the person in the browser always sees who holds the session and has a way to take it back.
Works with
| Supervisor | How it connects | What it needs |
|---|---|---|
herdr-core (hc) |
in-process tty via its own HC_TERMINAL_ID (or DSH_SUP_TERMINAL_ID when the host exports one) and the control socket with dsh-sup in its PTY; both are open at once |
a dsh web started inside the supervised terminal, plus any PTY for the forwarder |
| Any supervisor that reads OSC 7501 | control socket + dsh-sup in its own PTY |
it must read the PTY's OSC output and be able to write lines to that PTY's stdin |
| Another supervisor wanting in-process mode | set DSH_SUP_TERMINAL_ID |
the variable is read, never written, by this package; any supervisor can set it |
GUI-only dsh web |
nothing | the plugin exposes an inert socket and nothing else |
Quick start
Install
Plugins page — in DeepSeek Harness open the Plugins page, click Add plugin, enter dsh-supervision and install; then restart as the host asks. The GUI writes into the web profile, the same profile the command below targets.
CLI — for scripted setups:
dsh plugin --profile web add dsh-supervision # the plugin, into the web profile
npm i -g dsh-sup # the forwarder, for the supervisor's PTY
dsh-sup is a separate package because it runs as its own program in the supervisor's PTY. Pin a version with @<version>.
Connect a session
dsh-sup # create a session in the dsh web process's directory
dsh-sup --cwd ~/work/project # create it elsewhere (resolved here, then sent absolute)
dsh-sup attach session-abc123 # attach an existing session (or: dsh-sup session-abc123)
dsh-sup --pid 4711 new # pick the backend when several dsh web processes run
dsh-sup list # list every session on every live backend
list prints one table for every live backend and binds nothing. PID names the dsh web process a row came from, so a multi-backend listing stays attributable:
SESSION PID CWD LIVE BRIDGED
session-4f2a… 4711 /Users/me/work yes -
Backends are found through $DSH_HOME/supervision (~/.dsh/supervision when DSH_HOME is unset). A bridge needs exactly one live backend; with several, the refusal names each candidate's pid and socket, and --pid picks one. --cwd applies to new and list only; list without it shows every session, with the CWD column naming where each one lives.
While a bridge is bound the terminal runs in raw mode, so Ctrl+C would otherwise arrive as a stray byte and be swallowed. The plugin restores the terminal and delivers the signal to dsh web, so the process still stops normally from its own terminal.
Empty sessions stay out of the web UI. A session created here is attached to its project, but the DSH sidebar lists a session only after its first message. Until then it exists in dsh-sup list and on the bridge, with no row under the project. Send one line from the terminal (or attach and steer) and it appears.
Answer an approval
Ask where the session stands, then answer the parked ask by id:
/status
session=session-4f2a… state=blocked turn=3 asks=3f9ac1
/approve 3f9ac1
[ask 3f9ac1] bash → allowed-once (source=supervisor)
/y and /n answer the only parked ask; with more than one parked they refuse and ask you to name it.
Take back control
While a session is bridged the GUI shows a supervision banner above the composer — Supervised from <terminal> (pid <n>) · approvals are answered there — with a Take back control button. It posts to the reclaim route; the bridge is dropped, the supervisor reports the clear record, and a dsh-sup forwarder exits 0.
How it reports
One dsh web process owns one session tree; the plugin mounts in it and opens its control socket, plus an in-process tty when the process runs inside a supervised terminal.
dsh web (the process you already run)
└── dsh-supervision plugin
├── control socket $DSH_HOME/supervision/<pid>-<8hex>.sock (0600)
│ └── dsh-sup in the supervisor's PTY ── OSC 7501 + echo lines ──▶ supervisor (reads OSC 7501)
└── in-process tty (added when DSH_SUP_TERMINAL_ID or HC_TERMINAL_ID is set)
└── OSC 7501 written to this process's own /dev/tty
Activation. The plugin always opens its socket discovery surface — the listener, the 0600 socket and the <pid>-<8hex>.json registry entry that names it — so a forwarder has something to connect to. The effect layer (session listeners, OSC reports, echo lines, stdin handling) mounts per bridge, which is the same fact as "a forwarder is connected". A GUI-only dsh web therefore exposes an inert socket and nothing else. With DSH_SUP_TERMINAL_ID non-empty (or herdr-core's HC_TERMINAL_ID) the process also supervises the terminal it runs in: the same records go to its own /dev/tty, while the socket stays open for a dsh-sup elsewhere. A session still has exactly one bridge across both surfaces, so a second attach is refused as busy. If the terminal cannot be opened — no /dev/tty and a non-TTY stdout, or a stdin that is not a terminal — the plugin writes the reason to stderr and keeps the socket surface.
For implementers & agents
This half is reference material for implementers and coding agents; if you only want to use the plugin, jump to Links.
Repo map
| Path | What lives there |
|---|---|
src/ |
the host plugin and the browser supervision banner (src/client/) |
packages/protocol |
the OSC 7501 encoder and the NDJSON frame codec |
packages/cli |
the dsh-sup forwarder |
lib/ |
build output |
docs/ |
figures and their captions |
.agents/adr and .agents/knowledge |
decisions and the architecture notes behind them |
.changes/ |
release fragments |
locale/ |
plugin-page strings |
Supervision protocol
The facts a supervisor needs, all of them on this repository's wire:
Socket and registry. One Unix socket per dsh web process, mode 0600, at $DSH_HOME/supervision/<pid>-<8hex>.sock, next to a <pid>-<8hex>.json entry {proto, pid, socket, startedAt, dshVersion} written atomically and mode 0600. A session has exactly one bridge: a second connection claiming it is refused with error code busy and closed. Frames are NDJSON, PROTO is 1, one frame is at most 64 KiB; the forwarder splits its own stdin on \r, \n or \r\n and sends one line frame per input line.
| Direction | Frames |
|---|---|
| forwarder → plugin | hello (attach a session, new {cwd}, or bare), line {text}, list {cwd?}, bye |
| plugin → forwarder | welcome, osc {data}, echo {text}, sessions {items}, error {code, message}, bye {reason} |
error.code is one of busy, unknown-session, create-failed, locked, protocol, internal.
OSC 7501 output. One record is replaced whole on every report, so every field is restated:
ESC ] 7501 ; state=<idle|working|done|blocked|error>[ :id=<child id>][ :kind=<permission|question|auth>][ :app=<word>][ :title=<base64>][ :msg=<base64>] ST
A clear is state=clear for the root, state=clear:id=<id> for one child record. kind is legal only on blocked; a parked approval is the root blocked:kind=permission record carrying msg=<tool> <reason> [ask <id>]. Caps: sequence under 4096 bytes, title 192 bytes, msg 2048 bytes, id 128 bytes as /-joined segments of at most 32 bytes each (8 segments). The root word is blocked > working > done / error / idle, and a parked approval outranks a live turn. The reporting surface is /dev/tty on both paths (stdout when it is a TTY); that is what the supervisor reads, not the plugin's or the forwarder's stdout.
Routes, served only when the host mounts a webserver:
| Route | Answer |
|---|---|
GET /dsh-supervision/state |
{"proto":1,"sessions":{"<session id>":{"bridged":true,"terminal":"…","pid":<n>,"since":<ms>,"asks":["<ask id>"]}}}; 405 for another method |
POST /dsh-supervision/reclaim |
content-type: application/json, body {"session":"<id>"} under 4 KiB → 200 {"reclaimed":true}; 409 {"reclaimed":false} when no bridge held it; 415 for another content type, 400 for a bad body |
Echo lines the supervisor reads, in the order they are written: [turn <n>] <state> on a state change, [ask <id>] <tool> parked (<session>), [ask <id>] <tool> → <outcome> (source=supervisor|attached), the /status answer above, refused: <n> approval(s) parked, new failed: …, attach failed: …, unknown command /<verb>.
Exit codes of dsh-sup: 0 clean bye, 1 backend lost, 2 usage or no live backend, 3 busy, 4 locked, 5 create-failed, 6 protocol (a frame this process refused, so the backend is alive).
Configuration
Six keys, all of them read by src/index.ts; an unknown shape is refused with a field-level issue instead of half-applying.
| Key | Default | What it does |
|---|---|---|
enabled |
true |
Mount anything at all. false is a hard off switch: no socket, no routes, no banner. |
programStatus |
true |
Emit OSC 7501 reports. Off leaves the socket answering list only. |
echo |
true |
Mirror state changes and approval decisions as echo lines for the supervisor. |
app |
"dsh" |
The program word every report restates. |
titleFrom |
"cwd-basename" |
Where the root title comes from: cwd-basename, session-title, or none. |
socketDir |
$DSH_HOME/supervision |
Registry and socket directory. |
Environment variables it reads:
| Variable | Read by | Effect |
|---|---|---|
DSH_HOME |
plugin and dsh-sup |
Registry directory base; defaults to ~/.dsh. |
DSH_SUP_TERMINAL_ID |
plugin | Non-empty ⇒ an additional in-process tty transport; the control socket and registry stay open. |
DSH_SUP_TERMINAL_NAME |
plugin | The supervisor label the state route reports; defaults to dsh-sup. |
HC_TERMINAL_ID |
plugin | herdr-core compatibility: the same transport, read only when DSH_SUP_TERMINAL_ID is unset or empty. |
HC_TERMINAL_NAME |
plugin | herdr-core compatibility: the label, read only when DSH_SUP_TERMINAL_NAME is unset or empty. |
Both DSH_SUP_* variables and their HC_* fallbacks are read, never written, by this package.
Invariants
A change must not break these:
- One bridge per session. A second connection claiming a bridged session is refused with
busy; the in-process path holds one binding per process, and/attachswitches it rather than adding a second. - A record is replaced whole. Every report restates every field that still applies; a field left out is a field cleared.
- A parked approval outranks a live turn. The root word order is
blocked>working>done/error/idle, and a session switch (/new,/attach) is refused while an ask is parked. - Teardown settles every parked ask before the socket closes. Disconnect, dispose, reclaim or an asker's withdrawal resolves each ask as
cancelled; fail closed, never leave one dangling. - The terminal variables are read only. The in-process transport and its label are chosen by the environment, never by configuration, and nothing writes those variables.
- Caps are hard limits. The encoder drops a record it cannot express within its caps rather than truncating it, and a frame over 64 KiB is refused locally.
Links
| Doc | Content |
|---|---|
| CHANGELOG.md | released changes |
License
Released under the MIT License — the license field in package.json is authoritative.
No comments yet. Be the first to write one.