Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to the "vscode-acp" extension will be documented in this fil

Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.

## [Unreleased]

### Added
- **Remote agents over SSH**: any `acp.agents` entry can now set a `remote: { host, user?, port?, identityFile? }` block to run `command`/`args` on a remote machine instead of locally — the agent's stdio is tunneled through `ssh`. Useful for agents like Hermes Agent installed on a remote server. See the "Remote Agents" section in the README for setup and limitations.

## [0.2.0] - 2026-05-16

### Added
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,37 @@ You can add custom agent configurations in settings.

> **Note on Hermes Agent**: Hermes is a Python package, not an npm package. Install it via the [Hermes Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) (Linux/macOS/WSL2 only — Windows requires [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install)). Make sure `hermes` is on your `PATH` and launch VS Code from the same shell/venv. Configure credentials with `hermes model`.

## Remote Agents (e.g. a remote Hermes install)

ACP itself only speaks JSON-RPC over stdio ([Hermes's ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) confirm the agent side has no built-in network transport either), so there's no "host" field baked into the protocol. This extension bridges that by optionally tunneling an agent's stdio through `ssh`: add a `remote` block to any agent entry and `command`/`args` are executed on that host instead of locally.

```json
"acp.agents": {
"Hermes Agent (Remote)": {
"command": "hermes",
"args": ["acp"],
"remote": {
"host": "my-remote-host",
"user": "johnny",
"port": 22,
"identityFile": "~/.ssh/id_ed25519"
}
}
}
```

Requirements:
- Passwordless SSH access to the host (key-based auth — there's no interactive password prompt support).
- `hermes` on the remote account's `PATH`, with credentials already configured there (`hermes model`) — see the [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) for installing/configuring Hermes itself.
- The remote command runs via a login shell (`bash -lc`) so `PATH` picks up nvm/pyenv/venv entries from the remote profile, same as local agents.

Limitation: only the agent's stdio is tunneled. If the agent calls back into the editor's file-system/terminal capabilities with paths that only exist on the remote host, those calls still resolve against your *local* filesystem. For agents that need full remote file access, use [VS Code Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and install this extension inside that remote window instead — it declares no `extensionKind`, so it runs on the remote side there and `command` resolves against the remote `PATH` directly, with no `remote` block needed.

## Extension Settings

| Setting | Default | Description |
|---------|---------|-------------|
| `acp.agents` | *(11 agents)* | Agent configurations. Each key is the agent name, value has `command`, `args`, and `env`. |
| `acp.agents` | *(11 agents)* | Agent configurations. Each key is the agent name, value has `command`, `args`, `env`, and an optional `remote` (SSH) target. |
| `acp.autoApprovePermissions` | `ask` | How agent permission requests are handled: `ask` or `allowAll`. |
| `acp.defaultWorkingDirectory` | `""` | Default working directory for agent sessions. Empty uses current workspace. |
| `acp.logTraffic` | `true` | Log all ACP protocol traffic to the ACP Traffic output channel. |
Expand Down
45 changes: 44 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,50 @@
"properties": {
"acp.agents": {
"type": "object",
"description": "Agent configurations. Each key is the agent name, value has command, args, and env.",
"description": "Agent configurations. Each key is the agent name, value has command, args, env, and an optional remote (SSH) target.",
"additionalProperties": {
"type": "object",
"required": ["command"],
"properties": {
"command": {
"type": "string",
"description": "Executable to run (locally, or on the remote host if \"remote\" is set)."
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Command-line arguments."
},
"env": {
"type": "object",
"additionalProperties": { "type": "string" },
"description": "Environment variables for the agent process."
},
"remote": {
"type": "object",
"required": ["host"],
"description": "Run command/args on this remote host over SSH instead of locally (e.g. a remote Hermes Agent install).",
"properties": {
"host": {
"type": "string",
"description": "Remote hostname/IP, or a Host alias from ~/.ssh/config."
},
"user": {
"type": "string",
"description": "SSH user. Omit to use ssh's default (current user / ssh config)."
},
"port": {
"type": "number",
"description": "SSH port. Omit to use the default (22) or ssh config."
},
Comment on lines +314 to +317
"identityFile": {
"type": "string",
"description": "Path to a private key file. Supports a leading \"~\"."
}
}
}
}
},
"default": {
"GitHub Copilot": {
"command": "npx",
Expand Down
23 changes: 23 additions & 0 deletions src/config/AgentConfig.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import * as vscode from 'vscode';

/**
* SSH target for running an agent's command on a remote machine instead of
* spawning it locally. `command`/`args`/`env` are unchanged — they describe
* how to launch the agent on the remote host.
*/
export interface RemoteConfigEntry {
/** Remote hostname or IP (e.g., a host from your SSH config, or a raw address) */
host: string;
/** SSH user. Omit to use ssh's default (current user / ssh config). */
user?: string;
/** SSH port. Omit to use the default (22) or ssh config. */
port?: number;
/** Path to a private key file. Supports a leading "~". */
identityFile?: string;
}

/**
* Configuration for a single ACP agent.
*/
Expand All @@ -12,6 +28,13 @@ export interface AgentConfigEntry {
env?: Record<string, string>;
/** Display name */
displayName?: string;
/**
* If set, `command`/`args` are run on this remote host over SSH instead of
* locally. The agent process's stdio is tunneled through the ssh session,
* so it works with any ACP agent (e.g. Hermes Agent) already installed on
* the remote machine.
*/
remote?: RemoteConfigEntry;
}

/**
Expand Down
62 changes: 61 additions & 1 deletion src/core/AgentManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { spawn, ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { EventEmitter } from 'node:events';
import { log, logError } from '../utils/Logger';
import { sendEvent, sendError } from '../utils/TelemetryManager';
Expand Down Expand Up @@ -58,6 +60,45 @@ function resolveUnixShell(): { shell: string; useLoginFlag: boolean } {
return { shell: '/bin/sh', useLoginFlag: false };
}

function expandHome(path: string): string {
if (path === '~') {
return homedir();
}
if (path.startsWith('~/')) {
return join(homedir(), path.slice(2));
}
Comment on lines +67 to +69
return path;
}

/**
* Build the `ssh` command + args that tunnel an agent's stdio to a remote host.
*
* No `-t` flag is passed: ssh must NOT allocate a pty here, or the JSON-RPC
* stream on stdio would get mangled by terminal line-discipline processing.
* The remote command runs through `bash -lc` so the remote user's login
* profile (nvm, pyenv, venvs, etc.) is sourced — mirroring the local-spawn
* behavior below.
Comment on lines +78 to +80
*/
function buildRemoteSshInvocation(config: AgentConfigEntry): { command: string; args: string[] } {
const remote = config.remote!;
const sshArgs: string[] = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new'];

if (remote.port) {
sshArgs.push('-p', String(remote.port));
}
if (remote.identityFile) {
sshArgs.push('-i', expandHome(remote.identityFile));
}

const target = remote.user ? `${remote.user}@${remote.host}` : remote.host;
sshArgs.push(target);

const remoteCommandStr = [config.command, ...(config.args || [])].map(shellEscape).join(' ');
sshArgs.push(`bash -lc ${shellEscape(remoteCommandStr)}`);
Comment on lines +96 to +97

return { command: 'ssh', args: sshArgs };
}

export interface AgentInstance {
id: string;
name: string;
Expand All @@ -77,9 +118,28 @@ export class AgentManager extends EventEmitter {
*/
spawnAgent(name: string, config: AgentConfigEntry, cwd?: string): AgentInstance {
const id = `agent_${this.nextId++}`;
log(`Spawning agent "${name}" (${id}): ${config.command} ${(config.args || []).join(' ')}`);

const child = (() => {
if (config.remote) {
const { command, args } = buildRemoteSshInvocation(config);
log(`Spawning agent "${name}" (${id}) over SSH to ${config.remote.host}: ${config.command} ${(config.args || []).join(' ')}`);
sendEvent('agent/spawn/remote', { transport: 'ssh' });

// `cwd` (the local workspace folder) doesn't apply to a remote process —
// the remote command runs in the remote login shell's default directory.
// Note: this only tunnels the agent's stdio. If the agent calls back into
// the editor's fs/terminal capabilities with remote-side paths, those
// calls are still resolved against the *local* filesystem, so remote
// agents work best for conversation/execution that doesn't depend on
// client-side file access to remote-only paths.
return spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...(config.env || {}) },
});
}

log(`Spawning agent "${name}" (${id}): ${config.command} ${(config.args || []).join(' ')}`);

if (process.platform === 'win32') {
// On Windows, commands like npx are batch scripts (.cmd) that require
// shell resolution via cmd.exe.
Expand Down