DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

StarChen-Cycler /

StarChen-Cycler/dsh-ctl

Verified

Loopback HTTP control plane for a long-running DeepSeek Harness (dsh web) process: list, create, prompt, cancel and tail sessions over SSH.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@f9ef945b

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.2 or newer (dsh --version). Verified against 0.1.5-rc.2.
  • A profile built on @deepseek-ai/dsh-web-app (the web template, 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 / ::1 are refused with 403, independently of how the server was started.
  • No TLS, no user accounts. dsh web itself has no TLS; these routes rely on SSH for transport security and authentication.
  • Never bind a public interface. dsh web --host 0.0.0.0 is unsupported upstream, and these control routes are deliberately outside the /api fence. Reach them through SSH (ssh <host> ctl …) or an SSH tunnel.
  • Optional shared secret. Set config.secret in the profile patch to require Authorization: Bearer <secret> or X-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/control reports each pending item's placement (queued vs steering) on 0.1.5-rc.2. On 0.2.1-rc.5 the host stopped publishing that field, so the queue comes back empty even though mode: "steer" is still accepted. Use /ctl/control?raw=1 and /ctl/debug/shape to 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.
  • tail is a bounded read: it opens a follow stream, collects the opening snapshot, waits up to ms for 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/prompt rejects them with is 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:

  1. dsh plugin needs an executable literally named pnpm — see pnpm-shim.sh.
  2. The vendor-bundled Node is hardened-runtime signed and refuses npm's ad-hoc-signed native addon (different Team IDs). Use a normal Node.
  3. A pnpm-global install hides the platform package behind symlink realpaths — mac-link-native-binding.sh builds a NODE_PATH shim.

Windows specifics

  1. 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.
  2. Task Scheduler has no KeepAlive; the supervisor loop supplies it.
  3. Install with npm, not pnpm: the flat global layout removes the need for the NODE_PATH shim entirely.
  4. The remote shell is cmd.exe, which eats ? and & in URLs — always drive the control plane from a script, never an inline curl.

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.

—/ 5

No ratings yet

Verified DSH bundle

Commit f9ef945b5579

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout