dsh-ctl
A loopback HTTP control plane for a long-running DeepSeek Harness process.
It turns a headless dsh web server into something you can drive from a script over SSH:
list the sessions that exist, see which ones are actually running right now, create one,
inject a prompt, watch it work, read back the exchange, cancel it, rename it.
ssh <host> ctl sessions --running
ssh <host> ctl prompt <session-id> "run the migration and report back"
ssh <host> ctl tail <session-id>
The problem this solves
DeepSeek Harness ships five profiles. Only one of them is a server that stays alive:
| profile | lifecycle | can host a persistent session? |
|---|---|---|
desktop |
Electron shell | yes, but talks to its host over IPC, invisible to any other process |
headless |
one task, then exits | no |
acp |
stdio JSON-RPC, dies on stdin EOF | no — your SSH disconnect kills the agent |
sdk |
stdio JSON-RPC, same | no |
web |
HTTP server, runs until killed | yes |
So dsh web is the only surface that can keep a session warm while you are not
connected. But its own /api surface is gated behind a browser launch token,
a signed cookie, and a Host/Origin trust fence — painful to drive from a shell.
A DSH bundle plugin, however, can register first-class HTTP routes through
ctx.webServer, and those routes sit outside that fence. This plugin uses that
seam to expose a small, boring JSON API on loopback. Pair it with SSH and you get a
remote agent you can control without exposing a single port to the network.
Requirements
- DSH
0.1.5-rc.2or newer (dsh --version). Verified against0.1.5-rc.2. - A profile built on
@deepseek-ai/dsh-web-app(thewebtemplate, or any custom profile derived from it). - Node.js 18+ (DSH ships its own runtime; you do not need a system Node).
Install
# create a dedicated profile from the shipped web template
dsh --profile fleet --from-default-profile web
# install this plugin (from a path, a tarball, or npm once published)
dsh plugin --profile fleet add link:/absolute/path/to/dsh-ctl
# it is written into the profile automatically:
# dsh.profile.bundles = [dsh-base, dsh-web-app, dsh-ctl]
dsh plugin forwards its arguments to pnpm inside the profile directory and
records the bundle for you — no manual package.json editing required.
Run
dsh --profile fleet --port 17390 --no-open
--no-open suppresses the browser launch. The port is yours to choose; keep the
bind address at the default loopback. Add --patch overlays for anything else you
want in the same process (feishu listeners, schedules, MCP servers, …).
To keep it alive, use your platform's service manager — launchd on macOS,
systemd on Linux, a scheduled task or NSSM on Windows. A minimal macOS example:
<!-- ~/Library/LaunchAgents/dev.dsh.fleet.plist -->
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-lc</string>
<string>dsh --profile fleet --port 17390 --no-open</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
Use it
Everything below runs from any machine that can SSH to the host. No tunnel, no port forward, no credentials beyond your SSH key.
# health
ssh <host> ctl health
# every session, newest first
ssh <host> ctl sessions --limit 20
# only the ones executing right now
ssh <host> ctl sessions --running
# create a session rooted at a workspace
ssh <host> ctl create --cwd /srv/work
# drive it
ssh <host> ctl prompt <session-id> "summarise the failing tests"
# read back what happened (history snapshot + up to N seconds of live frames)
ssh <host> ctl tail <session-id>
# stop the current turn
ssh <host> ctl cancel <session-id>
The ctl helper in scripts/ wraps curl so you do not have to quote
JSON through two shells. Copy it onto the host, or call it locally with --host.
Driving a session that is already running
A session in the middle of a turn is not locked — you can still read it and you still have both ways to inject work into it.
# 1. is it running, and what is queued on it?
ssh <host> ctl control
# 2. read its durable state without waking the agent
ssh <host> ctl inspect <session-id>
# 3a. queue: wait for the current turn to finish
ssh <host> ctl prompt <session-id> "then also update the changelog" --mode queue
# 3b. steer: splice into the turn already running
ssh <host> ctl prompt <session-id> "stop, use the staging branch instead" --mode steer
--mode queue |
--mode steer |
|
|---|---|---|
| response | accepted: true |
accepted: true |
queue placement |
queued |
steering |
| lands in | the next turn | the current turn |
visible in tail before it lands |
no | no |
visible in control |
yes | yes |
Both return before delivery, and neither writes to the durable log until the turn
claims the message — so control (live state) is where you watch them, not tail.
Reading a running session
| want | endpoint | what you get |
|---|---|---|
| is it running | GET /ctl/sessions?running=1 |
live execution flag |
| live queue + stats | GET /ctl/control |
per-item placement, turns/steps/llmMs/toolMs/ttftMs/decodeTokens |
| durable state, no wake | GET /ctl/inspect?id= |
meta header plus the persisted event prefix |
| the actual messages | GET /ctl/tail?sessionId= |
history snapshot, then live frames |
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /ctl |
route index |
| GET | /ctl/health |
version, port, pid, platform, node, uptime, whether a secret is required |
| GET | /ctl/sessions?limit=&running=1 |
sessions with sessionId, title, cwd, agentPreset, model, running, blank, updatedAt |
| GET | /ctl/session?id= |
one session |
| GET | /ctl/control?ms=&raw=1 |
live queue (placement) and running stats |
| GET | /ctl/inspect?id= |
durable header + event prefix without activating the agent |
| GET | /ctl/tail?sessionId=&frames=&ms= |
history snapshot plus live frames, normalised |
| POST | /ctl/create |
{ cwd?, sessionId?, agentPreset? } |
| POST | /ctl/prompt |
{ sessionId, text, mode?, requestId? } — mode: queue/steer |
| POST | /ctl/cancel |
{ sessionId } |
| POST | /ctl/rename |
{ sessionId, title } |
| GET | /ctl/debug/shape?sessionId=&type= |
read-only schema introspection (see below) |
Full request/response shapes: docs/API.md.
Why running matters
GET /ctl/sessions?running=1 is the answer to "what is the agent doing now".
It reflects the owning process's live state, not the on-disk history, so it is the
one thing you cannot get by reading session archives. Verified to flip true within
~300 ms of a prompt and back to false when the turn settles.
Security model
- Loopback only. Requests whose socket address is not
127.0.0.1/::1are refused with403, independently of how the server was started. - No TLS, no user accounts.
dsh webitself has no TLS; these routes rely on SSH for transport security and authentication. - Never bind a public interface.
dsh web --host 0.0.0.0is unsupported upstream, and these control routes are deliberately outside the/apifence. Reach them through SSH (ssh <host> ctl …) or an SSH tunnel. - Optional shared secret. Set
config.secretin the profile patch to requireAuthorization: Bearer <secret>orX-Dsh-Ctl-Secret: <secret>on every request.
- insert:
- id: dsh-ctl
name: dsh-ctl
config:
secret: change-me
Cross-version robustness
The published TypeScript declarations and the runtime objects disagree in places.
On 0.1.5-rc.2 the declarations advertise id, displayTitle and completed,
while the runtime summary actually carries:
{ sessionId, updatedAt, running, blank, cwd, projections: { values: { title, agentPreset, … } } }
and message parts live at different paths per record type:
user/message -> event.data.content[]
assistant/message -> event.data.message.content[]
This plugin reads every field defensively (old name, new name, then absent) and, when
something still does not line up, exposes /ctl/debug/shape so you can see the real
key paths on your build instead of guessing. That is how the paths above were found.
Compatibility
| DSH version | Platform | Status |
|---|---|---|
0.1.5-rc.2 |
Windows 10/11 | verified — every endpoint exercised against a live host (see docs/VERIFICATION.md) |
0.2.1-rc.5 |
macOS arm64 | verified — sessions, create, prompt, inspect, tail |
0.2.1-rc.5 |
Windows 10 22H2 | verified — sessions, control plane, scheduled-task persistence |
That table is the DSH version, not the plugin's own version (0.1.0). After any
DSH upgrade, re-run /ctl/debug/shape and /ctl/control?raw=1 before trusting a
field.
Limitations
- Queue visibility is version-dependent.
GET /ctl/controlreports each pending item'splacement(queuedvssteering) on0.1.5-rc.2. On0.2.1-rc.5the host stopped publishing that field, so the queue comes back empty even thoughmode: "steer"is still accepted. Use/ctl/control?raw=1and/ctl/debug/shapeto re-confirm on any new version. - Schedules and other live-owner features only fire while the process holds a live agent for that session; a closed session keeps its reminders overdue until resumed. Keep the daemon running if you depend on them.
tailis a bounded read: it opens a follow stream, collects the opening snapshot, waits up tomsfor new frames, then closes. It is not a persistent subscription.- Session deletion and forking are not exposed.
- The plugin does not manage MCP servers or other plugins; it only controls sessions.
- Sessions owned by subagent routing cannot be prompted;
POST /ctl/promptrejects them withis owned by subagent routing. Target root sessions.
Platform notes
Both platforms need the same two things — a Node >= 22 and a running dsh web — but
the wiring differs, so each has its own launcher and persistence installer.
| macOS | Windows | |
|---|---|---|
| Foreground launcher | fleet-run.sh |
fleet-run.ps1 |
| Detached smoke test | mac-start-fleet.sh |
win-start-fleet.ps1 |
| Persistence | mac-install-launchd.sh (LaunchAgent) |
win-install-scheduledtask.ps1 + win-supervise-fleet.ps1 |
| Keep-alive | built in (KeepAlive) |
supplied by the supervisor loop |
| Verify | curl against loopback |
win-verify-fleet.ps1 |
| Guide | docs/MACOS.md | docs/WINDOWS.md |
macOS specifics
Three deployment traps, all covered in docs/MACOS.md:
dsh pluginneeds an executable literally namedpnpm— seepnpm-shim.sh.- The vendor-bundled Node is hardened-runtime signed and refuses npm's
ad-hoc-signed native addon (
different Team IDs). Use a normal Node. - A pnpm-global install hides the platform package behind symlink realpaths —
mac-link-native-binding.shbuilds aNODE_PATHshim.
Windows specifics
- A process started over SSH dies when that session closes. Task Scheduler,
not
nohup, is the answer. This is the one Windows behaviour that catches everyone. - Task Scheduler has no
KeepAlive; the supervisor loop supplies it. - Install with npm, not pnpm: the flat global layout removes the need for the
NODE_PATHshim entirely. - The remote shell is
cmd.exe, which eats?and&in URLs — always drive the control plane from a script, never an inlinecurl.
Details: docs/WINDOWS.md.
Enabling SSH on the target
If a machine has no SSH server yet, see scripts/ for per-platform
helpers. They are deliberately separate from the plugin: enabling a service needs
elevation, and the plugin must never do that on your behalf.
License
MIT — see LICENSE.
No comments yet. Be the first to write one.