# dsh-remote-subagent

[![npm](https://img.shields.io/npm/v/dsh-remote-subagent.svg)](https://www.npmjs.com/package/dsh-remote-subagent)
[![license](https://img.shields.io/npm/l/dsh-remote-subagent.svg)](./LICENSE)

[English](README-en.md) · [简体中文](README.md)

A DSH (DeepSeek Harness) remote-subagent plugin: a local session dispatches work to a **DSH running on another machine over SSH** with the `remote_agent` tool. The remote agent uses its own model, tools and sandbox; its thinking, tool calls, tool results and final answer stream back into the local session, and follow-up messages continue the same remote conversation — like the built-in subagent, but the work happens on the remote host.

```
local session ── remote_agent tool ──SSH──> remote dsh headless --json <task>
      ▲                                            │
      └──── run events (thinking / tool_call / tool_result / final) ◄──┘
```

## Why the remote `dsh headless --json`

DSH ships a headless entry: `--json` prints one run's process as newline-delimited JSON events, and `--session-id` resumes the same Session:

```bash
dsh headless --json '<task>'                     # new remote session
dsh headless --json --session-id <id> '<msg>'    # continue that session
```

The plugin reads that stream over the SSH exec channel, so:

- no inbound port, token or WebSocket protocol is needed on the remote;
- the remote agent uses **its own** model config, tools, sandbox and session store (sessions live in the remote `$DSH_HOME/sessions` and are visible in the remote DSH Web UI too);
- follow-ups use `--session-id`, matching subagent `send` semantics;
- the event stream *is* the working process — no extra instrumentation.

## Install

```bash
# install the bundle into the current profile from npm
dsh plugin --profile <profile> add dsh-remote-subagent
# or install dsh-remote-subagent from the DSH plugin manager / marketplace
```

Dependencies are installed automatically (`ssh2`). After enabling it: a "Remote Subagent" icon appears in the left rail, and the model gets the `remote_agent` tool.

## Where machines come from

Connection targets are **not** duplicated: the plugin reads dsh-remote's machine registry

```
$DSH_HOME/remote-workspaces/machines.json
```

Resolution order: plugin `config.machineId` → registry `currentId` → first saved machine → `config.machine` (standalone setups without dsh-remote). Password, private key (with passphrase) and SSH agent are supported. Host keys are TOFU: the first connection records the fingerprint in `$DSH_HOME/dsh-remote-subagent/known_hosts.json`; later connections must match.

## Tool: `remote_agent`

| action | What it does | Key parameters |
|---|---|---|
| `spawn` | Start a new remote run (new remote session) | `task`, `machine?`, `cwd?` |
| `wait` | Block until the run ends or the timeout elapses (default 30s, max 120s; a timeout does not stop the remote run) | `runId`, `waitSeconds?` |
| `get` | Status + the **latest** N process events + result | `runId`, `eventLimit?` |
| `send` | Follow-up message in the same remote conversation (new run with `--session-id`, turn+1) | `runId`, `task` |
| `list` | Recent runs | `limit?` |
| `cancel` | Terminate the remote process (pid-file `kill -TERM`) | `runId` |
| `machines` | Available remote machines | — |

Typical loop:

```
remote_agent({action:"spawn", task:"Run a read-only health check in /opt/app: disk, memory, listening ports, then summarize"})
  → runId
remote_agent({action:"wait", runId, waitSeconds:120})
remote_agent({action:"get", runId, eventLimit:30})
remote_agent({action:"send", runId, task:"List the listening ports again, sorted by port"})
```

**Reuse semantics**: `spawn` always creates a new remote session (like subagent `spawn`); `send` continues the same remote session as the linked run (like subagent `send_message`). Concurrent `spawn`s are independent sessions with no shared context; one session runs one turn at a time. Run history and `sessionId` persist locally, so `send` still resumes after a DSH restart.

## Panel (Client half)

Left rail → "Remote Subagent":

- left: new-task form (machine + task + dispatch) and the run list (state dot / task / machine / elapsed);
- right: run detail (state, remote session id, cwd, turn, event count, tokens), a **live event timeline** (1.5s incremental polling, auto-scroll), the result box, cancel/refresh, and a follow-up input.

Data endpoints (same-origin; Desktop uses the `/api` prefix, Web the bare path):

```
GET  /dsh-remote-subagent/runs
GET  /dsh-remote-subagent/run?id=<run>&from=<seq>&limit=<n>
POST /dsh-remote-subagent/action   {action: spawn|send|cancel, ...}
```

## Configuration (optional)

Plugin config lives under `config:` in the profile's `cordis.patch.yml`; every field is optional:

| Field | Default | Meaning |
|---|---|---|
| `machineId` | `''` | Default machine (id / name / host) |
| `machinesFile` | `''` | Alternative machine registry path |
| `headlessCommand` | `dsh headless` | Remote command prefix |
| `cwd` | `''` | Remote working directory (empty = machine home) |
| `maxConcurrent` | `4` | Concurrent run limit |
| `timeoutSeconds` | `900` | Per-run limit; the remote process is terminated on timeout |
| `maxEvents` | `4000` | Events kept per run |
| `connectTimeoutMs` | `20000` | SSH connect timeout |

## Local data

- `$DSH_HOME/dsh-remote-subagent/runs/<runId>.json`: per-run metadata + event transcript (history survives restarts; a run in flight during a restart is marked failed/interrupted but keeps its session id so `send` can resume it);
- `$DSH_HOME/dsh-remote-subagent/known_hosts.json`: TOFU host fingerprints.

## Verified state (v0.1.0, 2026-11-04)

Verified against a Linux remote (DSH 0.2.0-rc.2, SSH password auth):

- `spawn` → 11s / 23 events / turn 1, including `tool_call` and `tool_result`;
- `send` resumed the same `session-*`; turn 2 correctly used the previous context and answered the same value;
- after a DSH restart plus plugin hot reload, `send` still resumed the same remote session (turn 3);
- `cancel` left no `dsh headless` process on the remote and cleaned its pid file;
- panel slot registrations (`sidebar.panellist` id=`remote-agents`, `main` key=`remote-agents`) and all three panel endpoint handlers are covered by tests/evidence.

Unit tests (offline, no network): machine resolution order and errors, event parsing/paging/resume/cancel/persistence, panel routes, SSH quoting and TOFU fingerprints, and Client slot registration.

## Known limits

- The remote needs `dsh` on the SSH login PATH with a usable headless profile; older remote versions without `--json` / `--session-id` are unsupported;
- POSIX remotes only (commands are wrapped in `sh -c`); Windows remotes are not implemented;
- dsh-remote's `useSshConfig` alias machines and ProxyJump hops are not supported (use a concrete host entry);
- the remote run's model/tools/sandbox are entirely the remote profile's decision; the local side cannot change them;
- `wait` timeout does not stop the remote run; there is no mid-run steering — `wait` or `cancel`, then `send`;
- the stream covers one headless turn; if the remote agent moves work into a background job, that job's later output is not part of this run's stream.

## Rollback

- Uninstall: remove the `dsh-remote-subagent` bundle from the profile;
- Data: delete `$DSH_HOME/dsh-remote-subagent/` (run history + TOFU fingerprints);
- No persistent remote changes: only `/tmp/dsh-remote-subagent-*.pid` (removed automatically on normal completion) and remote session directories.

## License

MIT
