pi-ssh-remote 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +174 -12
- package/README.zh-CN.md +244 -13
- package/index.ts +210 -39
- package/package.json +1 -1
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
|
|
2
|
+
Agent-first English documentation for pi-ssh-remote. It explains the extension's design, transparent tool routing, persistent workspace model, safety boundaries, compelling workflows, commands, and installation requirements.
|
|
3
3
|
-->
|
|
4
4
|
|
|
5
5
|
# pi-ssh-remote
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**SSH designed for agents—not just for terminals.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[中文文档](README.zh-CN.md) · [Community](https://linux.do/)
|
|
10
|
+
|
|
11
|
+
`pi-ssh-remote` turns a remote machine into Pi's active workspace. After connecting, the agent keeps using its normal `read`, `write`, `edit`, and `bash` tools, but those operations run on the remote server. There is no need to wrap every action in `ssh ...`, copy files back and forth, or make the model reason about two unrelated shells.
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
You: Connect to ssh root@gpu-box -p 2202, open /srv/training,
|
|
15
|
+
find why the latest run failed, fix it, and restart it in the
|
|
16
|
+
background. Return the PID and log path.
|
|
17
|
+
|
|
18
|
+
Pi: connects → changes the persistent remote cwd → reads logs →
|
|
19
|
+
edits remote files → launches the job on the GPU server
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Designed for agent workflows
|
|
23
|
+
|
|
24
|
+
### The remote machine becomes the agent's workspace
|
|
25
|
+
|
|
26
|
+
Once connected, Pi's file tools, shell tool, and `!` user shell commands are transparently routed over SSH. The agent can inspect a repository, search logs, edit code, and run tests with the same tool interface it uses locally.
|
|
27
|
+
|
|
28
|
+
### Agent control plane, human control plane
|
|
29
|
+
|
|
30
|
+
The extension exposes `ssh_remote_control` for the agent and `/remote` for the user. You can ask Pi to connect, inspect status, change directory, add a server note, execute a command, create a tunnel, or return to local work in natural language—and still take direct control whenever you want.
|
|
31
|
+
|
|
32
|
+
### Stateful endpoints instead of disposable SSH commands
|
|
33
|
+
|
|
34
|
+
Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible.
|
|
35
|
+
|
|
36
|
+
### Resilient and bounded by default
|
|
37
|
+
|
|
38
|
+
Dropped connections are automatically re-established during the active session. Remote commands have a 30-second default timeout, oversized output is bounded before it reaches the model context, and full truncated output is preserved in a temporary file.
|
|
39
|
+
|
|
40
|
+
### Hybrid local/remote workflows
|
|
41
|
+
|
|
42
|
+
Tunnel mode forwards remote services to localhost while returning Pi's tools to the local machine. This is useful when the backend runs on a GPU server but the client, browser automation, or integration code lives locally.
|
|
43
|
+
|
|
44
|
+
## Why not just run `ssh` in Bash?
|
|
45
|
+
|
|
46
|
+
| Plain SSH command | `pi-ssh-remote` |
|
|
47
|
+
|---|---|
|
|
48
|
+
| Each tool call must wrap or reconstruct SSH | All agent file and shell tools route automatically |
|
|
49
|
+
| Working directory is easy to lose between calls | Remote cwd is persistent and visible |
|
|
50
|
+
| The model must track whether it is local or remote | Pi's prompt and footer identify the active workspace |
|
|
51
|
+
| Disconnects break the workflow | Active-session connections automatically reconnect |
|
|
52
|
+
| Tunneling and remote editing are separate setups | Workspace routing and port forwarding share one control plane |
|
|
53
|
+
| Large output can flood context | Preview and model-output limits are built in |
|
|
10
54
|
|
|
11
55
|
## Install
|
|
12
56
|
|
|
@@ -14,23 +58,141 @@ Use Pi's file and shell tools on a persistent remote SSH workspace. Supports mul
|
|
|
14
58
|
pi install npm:pi-ssh-remote
|
|
15
59
|
```
|
|
16
60
|
|
|
17
|
-
|
|
61
|
+
Requires Node.js 20+. The remote server must provide Bash, SFTP, and GNU `timeout`.
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
18
64
|
|
|
19
65
|
```text
|
|
20
|
-
/remote ssh
|
|
21
|
-
/remote cd /
|
|
66
|
+
/remote ssh root@gpu-box.example.com -p 2202
|
|
67
|
+
/remote cd /srv/project
|
|
68
|
+
/remote config note H100 training server
|
|
22
69
|
/remote status
|
|
23
|
-
/remote off # return to local tools
|
|
24
70
|
```
|
|
25
71
|
|
|
26
|
-
|
|
72
|
+
From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
SSH remote H100 training server (root@gpu-box.example.com:2202):/srv/project
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Return to local tools with:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
/remote off
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Examples
|
|
85
|
+
|
|
86
|
+
### 1. Let the agent investigate and repair a remote failure
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Connect to ssh ubuntu@training.example.com -p 22 and work in /opt/app.
|
|
90
|
+
Inspect the failed deployment, trace the error through the logs and source,
|
|
91
|
+
make the smallest safe fix, run the relevant tests, and show me the diff.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The connection is established once. Subsequent reads, searches, edits, and tests are ordinary Pi tool calls routed to the server.
|
|
95
|
+
|
|
96
|
+
### 2. Launch a long GPU job without blocking the agent
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
On the current remote server, validate the training command first. Then launch
|
|
100
|
+
it with nohup in the background, redirect stdout and stderr from process start,
|
|
101
|
+
and report the PID, output directory, and log path. Verify that the process is
|
|
102
|
+
still alive and that the log has started.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The default foreground timeout prevents an accidental long-running command from occupying the agent indefinitely, while an explicitly backgrounded job continues on the server.
|
|
106
|
+
|
|
107
|
+
### 3. Keep several machines understandable
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
/remote ssh root@10.0.0.21 -p 22
|
|
111
|
+
/remote config note 8xH100 training
|
|
112
|
+
/remote config cwd /srv/train
|
|
113
|
+
|
|
114
|
+
/remote ssh ubuntu@staging.example.com -p 2222
|
|
115
|
+
/remote config note staging API
|
|
116
|
+
/remote config cwd /opt/service
|
|
117
|
+
|
|
118
|
+
/remote config
|
|
119
|
+
/remote use root@10.0.0.21:22
|
|
120
|
+
/remote
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Endpoint notes and working directories survive Pi restarts because they are stored in the local remote configuration.
|
|
124
|
+
|
|
125
|
+
### 4. Expose a remote service while editing locally
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
/remote config forward 7860:127.0.0.1:7860
|
|
129
|
+
/remote forward
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Now `localhost:7860` reaches the service on the SSH server, while Pi's file and shell tools remain local. This is ideal for a remote model server paired with a local UI or client repository.
|
|
133
|
+
|
|
134
|
+
Stop the tunnels with:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
/remote unforward
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 5. Ask Pi directly
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
Connect to my configured remote, switch to /srv/api, and inspect its Git status.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
Add the note "production read-only" to this endpoint and tell me which remote
|
|
148
|
+
workspace is active.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
Forward the remote service on port 8000 to localhost:8000, but keep my coding
|
|
153
|
+
tools on the local repository.
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
These requests are handled through the agent-facing `ssh_remote_control` tool; slash commands are optional.
|
|
157
|
+
|
|
158
|
+
## Command reference
|
|
159
|
+
|
|
160
|
+
| Command | Purpose |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `/remote ssh USER@HOST -p PORT` | Save, select, and connect to an endpoint |
|
|
163
|
+
| `/remote` | Connect to the selected endpoint or prompt for one |
|
|
164
|
+
| `/remote config` | List saved endpoints and settings |
|
|
165
|
+
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
166
|
+
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
167
|
+
| `/remote config note --clear` | Clear its note |
|
|
168
|
+
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
169
|
+
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
170
|
+
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
171
|
+
| `/remote forward [MAPPING...]` | Start tunnels and keep Pi's tools local |
|
|
172
|
+
| `/remote unforward` | Stop extension-managed tunnels |
|
|
173
|
+
| `/remote exec COMMAND` | Run one command in the remote cwd |
|
|
174
|
+
| `/remote exec --timeout 60 COMMAND` | Override the command timeout |
|
|
175
|
+
| `/remote exec --lines 20 COMMAND` | Override collapsed preview lines |
|
|
176
|
+
| `/remote config display-lines 10` | Set default collapsed preview lines |
|
|
177
|
+
| `/remote status` | Show the active workspace |
|
|
178
|
+
| `/remote reload` | Reconnect the active workspace |
|
|
179
|
+
| `/remote off` | Disconnect and return tools to local execution |
|
|
180
|
+
| `/remote forget` | Disconnect and clear the in-memory password |
|
|
181
|
+
|
|
182
|
+
## Persistence, output, and security
|
|
183
|
+
|
|
184
|
+
Endpoint configuration is stored locally in:
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
~/.pi/agent/ssh-remote-config.json
|
|
188
|
+
```
|
|
27
189
|
|
|
28
|
-
|
|
190
|
+
Saved values include endpoints, active endpoint, notes, remote working directories, forwards, and preview settings. Passwords are **never written to this file**: SSH agent authentication is preferred, and prompted passwords remain only in process memory.
|
|
29
191
|
|
|
30
|
-
|
|
192
|
+
New or changed host keys require interactive confirmation and are stored separately from OpenSSH. Remote output sent to the model is limited to 2,000 lines or 50 KB; oversized complete output is written to a temporary local file. Preview-line settings affect only the collapsed UI.
|
|
31
193
|
|
|
32
|
-
|
|
194
|
+
## Current SSH scope
|
|
33
195
|
|
|
34
|
-
|
|
196
|
+
The extension currently supports direct SSH commands with `-p` and `-l`. It does not yet consume `~/.ssh/config`, `IdentityFile`, or ProxyJump settings.
|
|
35
197
|
|
|
36
198
|
MIT licensed.
|
package/README.zh-CN.md
CHANGED
|
@@ -1,12 +1,89 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
pi-ssh-remote
|
|
2
|
+
pi-ssh-remote 中文说明文档,重点介绍它面向 Pi Agent 的设计、主要能力、常见使用场景、命令和安全机制。
|
|
3
3
|
-->
|
|
4
4
|
|
|
5
5
|
# pi-ssh-remote
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**让 Pi 像操作本地项目一样操作远程服务器。**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
[English Docs](README.md) · [社区](https://linux.do/)
|
|
10
|
+
|
|
11
|
+
`pi-ssh-remote` 是一个专门面向 Pi Agent 的 SSH 远程开发插件。
|
|
12
|
+
|
|
13
|
+
它不只是帮你打开一个远程终端。连接服务器后,Pi 原有的 `read`、`write`、`edit`、`bash` 等工具会自动切换到远端。Agent 不需要反复拼接 SSH 命令,也不需要先把代码下载到本地再修改,可以直接在服务器上查看文件、分析日志、修改代码和运行任务。
|
|
14
|
+
|
|
15
|
+
例如,你可以直接对 Pi 说:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
连接 ssh root@gpu-box -p 2202,进入 /srv/training。
|
|
19
|
+
检查最近一次训练为什么失败,修复问题后在后台重新启动,
|
|
20
|
+
最后把 PID 和日志路径发给我。
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pi 会依次完成连接服务器、切换目录、读取日志、修改文件和启动任务。整个过程中,它使用的仍然是熟悉的 Pi 工具,只是执行位置变成了远程服务器。
|
|
24
|
+
|
|
25
|
+
## 为什么它更适合 Agent
|
|
26
|
+
|
|
27
|
+
### 不需要给每一步都套一层 SSH
|
|
28
|
+
|
|
29
|
+
普通做法通常是让 Agent 不断执行:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
ssh user@host "cd /path && ..."
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
命令一多,目录、引号、环境变量和连接状态都很容易出错。使用本插件后,只需要连接一次,后续文件读写和 Shell 操作都会自动在远端执行。
|
|
36
|
+
|
|
37
|
+
### Agent 知道自己正在操作哪台服务器
|
|
38
|
+
|
|
39
|
+
插件会把当前远程目录和服务器信息写入 Pi 的上下文,并显示在底部状态栏:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
这样无论是用户还是 Agent,都能随时确认当前操作发生在本地还是远端,减少误操作。
|
|
46
|
+
|
|
47
|
+
### 服务器配置会保留
|
|
48
|
+
|
|
49
|
+
每台服务器都可以单独保存:
|
|
50
|
+
|
|
51
|
+
- SSH 地址;
|
|
52
|
+
- 默认工作目录;
|
|
53
|
+
- 服务器备注;
|
|
54
|
+
- 端口转发配置。
|
|
55
|
+
|
|
56
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。这些配置保存在本地,重启 Pi 后仍然存在。
|
|
57
|
+
|
|
58
|
+
### 断线后可以自动恢复
|
|
59
|
+
|
|
60
|
+
当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。
|
|
61
|
+
|
|
62
|
+
### 对 Agent 上下文更友好
|
|
63
|
+
|
|
64
|
+
远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent。命令输出过大时,只会把受限内容放入模型上下文,完整输出会另外保存到临时文件,方便后续继续检查。
|
|
65
|
+
|
|
66
|
+
### 可以远端跑服务、本地改代码
|
|
67
|
+
|
|
68
|
+
端口转发模式下,可以把远端服务映射到 `localhost`,同时让 Pi 的文件和 Shell 工具继续操作本地项目。
|
|
69
|
+
|
|
70
|
+
这很适合下面这类场景:
|
|
71
|
+
|
|
72
|
+
- GPU 服务器运行模型,本地开发 Web UI;
|
|
73
|
+
- 远端启动 API,本地调试客户端;
|
|
74
|
+
- 远端运行训练监控,本地查看页面;
|
|
75
|
+
- 内网服务通过 SSH 隧道提供给本地工具使用。
|
|
76
|
+
|
|
77
|
+
## 和直接使用 SSH 有什么区别
|
|
78
|
+
|
|
79
|
+
| 直接执行 SSH | 使用 `pi-ssh-remote` |
|
|
80
|
+
|---|---|
|
|
81
|
+
| 每条命令都要重新拼接 SSH | 连接一次后,Pi 工具自动在远端执行 |
|
|
82
|
+
| 多次调用之间容易丢失目录 | 自动记住远程工作目录 |
|
|
83
|
+
| Agent 需要自己判断当前在哪台机器 | 系统上下文和底部状态栏会显示当前服务器 |
|
|
84
|
+
| 连接中断后需要手动恢复 | 当前会话内自动重连 |
|
|
85
|
+
| 文件修改、命令执行和端口转发各自处理 | 统一通过 `/remote` 和 Agent 工具管理 |
|
|
86
|
+
| 大量输出可能直接占满模型上下文 | 内置超时、折叠预览和输出上限 |
|
|
10
87
|
|
|
11
88
|
## 安装
|
|
12
89
|
|
|
@@ -14,23 +91,177 @@ pi-ssh-remote 的简明中文文档,说明扩展用途、安装方式、必要
|
|
|
14
91
|
pi install npm:pi-ssh-remote
|
|
15
92
|
```
|
|
16
93
|
|
|
17
|
-
|
|
94
|
+
本地要求 Node.js 20+。远程服务器需要提供 Bash、SFTP 和 GNU `timeout`。
|
|
95
|
+
|
|
96
|
+
## 快速开始
|
|
97
|
+
|
|
98
|
+
连接服务器:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
/remote ssh root@gpu-box.example.com -p 2202
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
设置远程工作目录和备注:
|
|
105
|
+
|
|
106
|
+
```text
|
|
107
|
+
/remote cd /srv/project
|
|
108
|
+
/remote config note H100 训练机
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
查看当前状态:
|
|
18
112
|
|
|
19
113
|
```text
|
|
20
|
-
/remote ssh USER@HOST -p PORT # 保存并连接
|
|
21
|
-
/remote cd /remote/project
|
|
22
114
|
/remote status
|
|
23
|
-
/remote off # 返回本地工具
|
|
24
115
|
```
|
|
25
116
|
|
|
26
|
-
|
|
117
|
+
连接成功后,Pi 的文件和 Shell 工具都会操作远程 `/srv/project`。
|
|
118
|
+
|
|
119
|
+
需要返回本地时执行:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
/remote off
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## 常见用法
|
|
126
|
+
|
|
127
|
+
### 1. 让 Pi 直接排查远程服务故障
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
连接 ubuntu@training.example.com,进入 /opt/app。
|
|
131
|
+
先检查部署日志和 Git 状态,找出失败原因。
|
|
132
|
+
如果需要修改代码,先说明原因,再做最小修改并运行相关测试,
|
|
133
|
+
最后把 diff 和测试结果发给我。
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
连接完成后,Pi 后续的读文件、查日志、改代码和跑测试都会直接在服务器上进行。
|
|
137
|
+
|
|
138
|
+
### 2. 启动长时间训练任务
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
在当前远程服务器检查训练命令和配置。
|
|
142
|
+
确认无误后用 nohup 在后台启动,并从一开始就重定向 stdout 和 stderr。
|
|
143
|
+
把 PID、输出目录和日志路径发给我,再检查一次进程是否仍在运行、日志是否已经开始写入。
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
插件默认限制前台命令的执行时间,但通过 `nohup` 等方式启动的后台任务可以继续在服务器运行。
|
|
147
|
+
|
|
148
|
+
### 3. 管理多台服务器
|
|
149
|
+
|
|
150
|
+
先配置训练机:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
/remote ssh root@10.0.0.21 -p 22
|
|
154
|
+
/remote config note 8xH100 训练机
|
|
155
|
+
/remote config cwd /srv/train
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
再配置预发布服务器:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
/remote ssh ubuntu@staging.example.com -p 2222
|
|
162
|
+
/remote config note 预发布 API
|
|
163
|
+
/remote config cwd /opt/service
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
查看并切换服务器:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
/remote config
|
|
170
|
+
/remote use root@10.0.0.21:22
|
|
171
|
+
/remote
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
|
|
175
|
+
|
|
176
|
+
### 4. 远端启动模型,本地开发界面
|
|
177
|
+
|
|
178
|
+
保存并启动端口转发:
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
/remote config forward 7860:127.0.0.1:7860
|
|
182
|
+
/remote forward
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
现在访问本地 `localhost:7860`,实际连接的是远程服务器上的 7860 端口;与此同时,Pi 的文件和 Shell 工具会留在本地,方便继续修改前端或客户端代码。
|
|
186
|
+
|
|
187
|
+
停止转发:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
/remote unforward
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 5. 直接用自然语言操作
|
|
194
|
+
|
|
195
|
+
不想记命令时,可以直接告诉 Pi:
|
|
196
|
+
|
|
197
|
+
```text
|
|
198
|
+
连接已配置的远程服务器,进入 /srv/api,然后检查 Git 状态。
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
把当前服务器备注为“线上只读机”,然后告诉我现在操作的是哪台服务器。
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```text
|
|
206
|
+
把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
210
|
+
|
|
211
|
+
## 命令说明
|
|
212
|
+
|
|
213
|
+
| 命令 | 作用 |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `/remote ssh USER@HOST -p PORT` | 保存并连接服务器 |
|
|
216
|
+
| `/remote` | 连接当前选中的服务器,或提示输入 SSH 地址 |
|
|
217
|
+
| `/remote config` | 查看已保存的服务器和配置 |
|
|
218
|
+
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
219
|
+
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
220
|
+
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
221
|
+
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
222
|
+
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
223
|
+
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
224
|
+
| `/remote forward [MAPPING...]` | 启动端口转发,并让 Pi 工具留在本地 |
|
|
225
|
+
| `/remote unforward` | 停止插件创建的端口转发 |
|
|
226
|
+
| `/remote exec COMMAND` | 在当前远程目录执行一次命令 |
|
|
227
|
+
| `/remote exec --timeout 60 COMMAND` | 单独设置本次命令的超时时间 |
|
|
228
|
+
| `/remote exec --lines 20 COMMAND` | 单独设置本次折叠显示的行数 |
|
|
229
|
+
| `/remote config display-lines 10` | 设置默认折叠显示行数 |
|
|
230
|
+
| `/remote status` | 查看当前连接和工作目录 |
|
|
231
|
+
| `/remote reload` | 重新连接当前服务器 |
|
|
232
|
+
| `/remote off` | 断开连接并返回本地 |
|
|
233
|
+
| `/remote forget` | 断开连接并清除内存中的密码 |
|
|
234
|
+
|
|
235
|
+
## 配置保存在哪里
|
|
236
|
+
|
|
237
|
+
服务器配置保存在本地:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
~/.pi/agent/ssh-remote-config.json
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
其中包括:
|
|
244
|
+
|
|
245
|
+
- 已保存的服务器;
|
|
246
|
+
- 当前选中的服务器;
|
|
247
|
+
- 服务器备注;
|
|
248
|
+
- 默认远程目录;
|
|
249
|
+
- 端口转发配置;
|
|
250
|
+
- 命令预览设置。
|
|
251
|
+
|
|
252
|
+
密码不会写入配置文件。插件会优先使用 SSH agent;如果需要手动输入密码,密码只会缓存在当前 Pi 进程的内存中。
|
|
27
253
|
|
|
28
|
-
##
|
|
254
|
+
## 安全与输出限制
|
|
29
255
|
|
|
30
|
-
|
|
256
|
+
- 第一次连接新服务器时,需要确认主机密钥;
|
|
257
|
+
- 主机密钥发生变化时,会再次要求确认;
|
|
258
|
+
- 远程命令默认 30 秒超时;
|
|
259
|
+
- 发送给模型的命令输出最多为 2,000 行或 50 KB;
|
|
260
|
+
- 超限的完整输出会保存到本地临时文件;
|
|
261
|
+
- 折叠显示行数只影响界面,不影响模型输出上限。
|
|
31
262
|
|
|
32
|
-
|
|
263
|
+
## 当前限制
|
|
33
264
|
|
|
34
|
-
|
|
265
|
+
目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config`,也不支持 `IdentityFile` 和 ProxyJump。
|
|
35
266
|
|
|
36
|
-
|
|
267
|
+
MIT License。
|
package/index.ts
CHANGED
|
@@ -23,13 +23,14 @@ import {
|
|
|
23
23
|
createReadTool,
|
|
24
24
|
createWriteTool,
|
|
25
25
|
formatSize,
|
|
26
|
+
keyHint,
|
|
26
27
|
truncateTail,
|
|
27
28
|
type BashOperations,
|
|
28
29
|
type EditOperations,
|
|
29
30
|
type ReadOperations,
|
|
30
31
|
type WriteOperations,
|
|
31
32
|
} from "@earendil-works/pi-coding-agent";
|
|
32
|
-
import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
33
|
+
import { CURSOR_MARKER, Key, Text, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
33
34
|
|
|
34
35
|
interface ParsedSsh {
|
|
35
36
|
host: string;
|
|
@@ -53,11 +54,13 @@ interface RemoteEndpointConfig {
|
|
|
53
54
|
sshCommand?: string;
|
|
54
55
|
remoteCwd?: string;
|
|
55
56
|
forwards?: string[];
|
|
57
|
+
note?: string;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
interface RemoteConfig {
|
|
59
61
|
activeEndpoint?: string;
|
|
60
62
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
63
|
+
displayLines?: number;
|
|
61
64
|
/** Legacy fields migrated into endpoints on the next config write. */
|
|
62
65
|
sshCommand?: string;
|
|
63
66
|
remoteCwd?: string;
|
|
@@ -74,6 +77,9 @@ const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
|
|
|
74
77
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
75
78
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
76
79
|
const FALLBACK_REMOTE_CWD = "~";
|
|
80
|
+
const DEFAULT_DISPLAY_LINES = 5;
|
|
81
|
+
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
82
|
+
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
77
83
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
78
84
|
const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
|
|
79
85
|
const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
|
|
@@ -151,6 +157,31 @@ function quote(value: string): string {
|
|
|
151
157
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
function parseDisplayLines(value: unknown): number {
|
|
161
|
+
const lines = typeof value === "number" ? value : Number(value);
|
|
162
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > DEFAULT_MAX_LINES) {
|
|
163
|
+
throw new Error(`Display lines must be an integer from 1 to ${DEFAULT_MAX_LINES}`);
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseRemoteTimeout(value: unknown): number {
|
|
169
|
+
const seconds = typeof value === "number" ? value : Number(value);
|
|
170
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_REMOTE_TIMEOUT_SECONDS) {
|
|
171
|
+
throw new Error(`Timeout must be a positive number no greater than ${MAX_REMOTE_TIMEOUT_SECONDS} seconds`);
|
|
172
|
+
}
|
|
173
|
+
return seconds;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function withRemoteTimeout(command: string, timeoutSeconds: number): string {
|
|
177
|
+
return `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -lc ${quote(command)}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function configuredDisplayLines(config = loadRemoteConfig()): number {
|
|
181
|
+
try { return parseDisplayLines(config.displayLines ?? DEFAULT_DISPLAY_LINES); }
|
|
182
|
+
catch { return DEFAULT_DISPLAY_LINES; }
|
|
183
|
+
}
|
|
184
|
+
|
|
154
185
|
function commandFromEndpointKey(key: string): string | undefined {
|
|
155
186
|
const match = key.match(/^([^@]+)@(.+):(\d+)$/);
|
|
156
187
|
if (!match) return undefined;
|
|
@@ -182,10 +213,14 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
182
213
|
}
|
|
183
214
|
if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
|
|
184
215
|
activeEndpoint ??= Object.keys(endpoints)[0];
|
|
216
|
+
let displayLines: number | undefined;
|
|
217
|
+
try { displayLines = config.displayLines === undefined ? undefined : parseDisplayLines(config.displayLines); }
|
|
218
|
+
catch { displayLines = undefined; }
|
|
185
219
|
|
|
186
220
|
return {
|
|
187
221
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
188
222
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
223
|
+
...(displayLines !== undefined ? { displayLines } : {}),
|
|
189
224
|
};
|
|
190
225
|
}
|
|
191
226
|
|
|
@@ -212,6 +247,11 @@ function activeSshCommand(config = loadRemoteConfig()): string | undefined {
|
|
|
212
247
|
return activeEndpointConfig(config)?.sshCommand;
|
|
213
248
|
}
|
|
214
249
|
|
|
250
|
+
function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()): string {
|
|
251
|
+
const note = endpointConfig(config, endpoint.command).note?.trim();
|
|
252
|
+
return note ? `${note} (${endpoint.label})` : endpoint.label;
|
|
253
|
+
}
|
|
254
|
+
|
|
215
255
|
function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
|
|
216
256
|
const config = loadRemoteConfig();
|
|
217
257
|
const key = cacheId(parseSshCommand(command));
|
|
@@ -241,15 +281,54 @@ function displayFingerprint(hex: string): string {
|
|
|
241
281
|
return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
|
|
242
282
|
}
|
|
243
283
|
|
|
244
|
-
function formatRemoteOutput(output: string)
|
|
245
|
-
const
|
|
246
|
-
|
|
284
|
+
function formatRemoteOutput(output: string) {
|
|
285
|
+
const truncation = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
286
|
+
const content = truncation.content || "Remote command completed.";
|
|
287
|
+
if (!truncation.truncated) return { text: content, content, truncation };
|
|
247
288
|
|
|
248
289
|
const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
|
|
249
290
|
const fullOutputPath = join(outputDir, "output.log");
|
|
250
291
|
writeFileSync(fullOutputPath, output, { encoding: "utf8", mode: 0o600 });
|
|
251
|
-
const
|
|
252
|
-
|
|
292
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
293
|
+
const limit = truncation.truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : "";
|
|
294
|
+
const text = `${content}\n\n[Showing lines ${startLine}-${truncation.totalLines} of ${truncation.totalLines}${limit}. Full output: ${fullOutputPath}]`;
|
|
295
|
+
return { text, content, truncation, fullOutputPath };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function previewRemoteOutput(output: string, displayLines: number): string {
|
|
299
|
+
return truncateTail(output, { maxLines: displayLines, maxBytes: DEFAULT_MAX_BYTES }).content || "Remote command completed.";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderRemoteControlResult(result: any, expanded: boolean, theme: any): Component {
|
|
303
|
+
const fallback = result.content?.find((item: any) => item.type === "text")?.text ?? "";
|
|
304
|
+
const details = result.details;
|
|
305
|
+
if (details?.action !== "exec") return new Text(fallback, 0, 0);
|
|
306
|
+
|
|
307
|
+
const output = details.output || fallback;
|
|
308
|
+
const displayLines = details.displayLines || DEFAULT_DISPLAY_LINES;
|
|
309
|
+
const warnings = [
|
|
310
|
+
...(details.fullOutputPath ? [`Full output: ${details.fullOutputPath}`] : []),
|
|
311
|
+
...(details.truncation?.truncated ? [`Truncated: showing ${details.truncation.outputLines} of ${details.truncation.totalLines} lines`] : []),
|
|
312
|
+
];
|
|
313
|
+
const warning = warnings.length ? warnings.join(". ") : undefined;
|
|
314
|
+
|
|
315
|
+
if (expanded) {
|
|
316
|
+
return new Text(`${output}${warning ? `\n${theme.fg("warning", `[${warning}]`)}` : ""}`, 0, 0);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
render(width: number) {
|
|
321
|
+
const styled = output.split("\n").map((line: string) => theme.fg("toolOutput", line)).join("\n");
|
|
322
|
+
const visualLines = new Text(styled, 0, 0).render(width);
|
|
323
|
+
const shown = visualLines.slice(-displayLines);
|
|
324
|
+
const skipped = visualLines.length - shown.length;
|
|
325
|
+
const hint = skipped > 0
|
|
326
|
+
? [theme.fg("muted", `... (${skipped} earlier lines, ${keyHint("app.tools.expand", "to expand")})`)]
|
|
327
|
+
: [];
|
|
328
|
+
return [...hint, ...shown, ...(warning ? [theme.fg("warning", `[${warning}]`)] : [])];
|
|
329
|
+
},
|
|
330
|
+
invalidate() {},
|
|
331
|
+
};
|
|
253
332
|
}
|
|
254
333
|
|
|
255
334
|
function probeFingerprint(config: ParsedSsh): Promise<string> {
|
|
@@ -298,16 +377,34 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
|
|
|
298
377
|
});
|
|
299
378
|
}
|
|
300
379
|
|
|
301
|
-
function execRemote(
|
|
380
|
+
function execRemote(
|
|
381
|
+
client: Client,
|
|
382
|
+
command: string,
|
|
383
|
+
allowFailure = false,
|
|
384
|
+
timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
|
|
385
|
+
): Promise<Buffer> {
|
|
386
|
+
const resolvedTimeout = parseRemoteTimeout(timeoutSeconds);
|
|
302
387
|
return new Promise((resolve, reject) => {
|
|
303
|
-
client.exec(command, (error, stream) => {
|
|
388
|
+
client.exec(withRemoteTimeout(command, resolvedTimeout), (error, stream) => {
|
|
304
389
|
if (error) return reject(error);
|
|
305
390
|
const stdout: Buffer[] = [];
|
|
306
391
|
const stderr: Buffer[] = [];
|
|
392
|
+
let locallyTimedOut = false;
|
|
393
|
+
const timer = setTimeout(() => {
|
|
394
|
+
locallyTimedOut = true;
|
|
395
|
+
stream.close();
|
|
396
|
+
}, (resolvedTimeout + 8) * 1000);
|
|
397
|
+
const cleanup = () => clearTimeout(timer);
|
|
307
398
|
stream.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
308
399
|
stream.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
400
|
+
stream.once("error", (streamError: Error) => {
|
|
401
|
+
cleanup();
|
|
402
|
+
reject(streamError);
|
|
403
|
+
});
|
|
309
404
|
stream.on("close", (code: number | null) => {
|
|
310
|
-
|
|
405
|
+
cleanup();
|
|
406
|
+
if (locallyTimedOut || code === 124 || code === 137) reject(new Error(`Remote command timed out after ${resolvedTimeout} seconds`));
|
|
407
|
+
else if (!allowFailure && code !== 0) reject(new Error(Buffer.concat(stderr).toString().trim() || `Remote command exited with code ${code}`));
|
|
311
408
|
else resolve(Buffer.concat(stdout));
|
|
312
409
|
});
|
|
313
410
|
});
|
|
@@ -383,8 +480,8 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
383
480
|
const status = (ctx: any) => {
|
|
384
481
|
currentCtx = ctx;
|
|
385
482
|
if (!remote) ctx.ui.setStatus("ssh-remote", undefined);
|
|
386
|
-
else if (routeRemoteTools) ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote ${remote
|
|
387
|
-
else ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote tunnel ${[...forwardServers.keys()].join(",") || remote
|
|
483
|
+
else if (routeRemoteTools) ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote ${endpointDisplayLabel(remote)}:${remote.cwd}`));
|
|
484
|
+
else ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote tunnel ${[...forwardServers.keys()].join(",") || endpointDisplayLabel(remote)}`));
|
|
388
485
|
};
|
|
389
486
|
|
|
390
487
|
const attachClient = (state: RemoteState) => {
|
|
@@ -392,7 +489,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
392
489
|
client.on("close", () => {
|
|
393
490
|
if (remote?.client !== client) return;
|
|
394
491
|
if (currentCtx) {
|
|
395
|
-
currentCtx.ui.setStatus("ssh-remote", currentCtx.ui.theme.fg("warning", `SSH remote reconnecting ${state
|
|
492
|
+
currentCtx.ui.setStatus("ssh-remote", currentCtx.ui.theme.fg("warning", `SSH remote reconnecting ${endpointDisplayLabel(state)}…`));
|
|
396
493
|
}
|
|
397
494
|
void reconnectRemote().catch((error) => {
|
|
398
495
|
if (currentCtx) currentCtx.ui.notify(`SSH remote automatic reconnection failed: ${(error as Error).message}`, "error");
|
|
@@ -431,7 +528,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
431
528
|
oldClient?.end();
|
|
432
529
|
if (currentCtx) {
|
|
433
530
|
status(currentCtx);
|
|
434
|
-
currentCtx.ui.notify(`SSH remote reconnected automatically: ${next
|
|
531
|
+
currentCtx.ui.notify(`SSH remote reconnected automatically: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
435
532
|
}
|
|
436
533
|
return next;
|
|
437
534
|
})().finally(() => { reconnectPromise = null; });
|
|
@@ -501,7 +598,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
501
598
|
}
|
|
502
599
|
|
|
503
600
|
let password = getCachedPassword(parsed);
|
|
504
|
-
ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${parsed
|
|
601
|
+
ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
|
|
505
602
|
try {
|
|
506
603
|
let next: RemoteState;
|
|
507
604
|
try {
|
|
@@ -526,7 +623,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
526
623
|
lastConnectionError = undefined;
|
|
527
624
|
saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
|
|
528
625
|
status(ctx);
|
|
529
|
-
ctx.ui.notify(`SSH remote connected: ${
|
|
626
|
+
ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
|
|
530
627
|
return next;
|
|
531
628
|
} catch (error) {
|
|
532
629
|
deleteCachedPassword(parsed);
|
|
@@ -629,23 +726,21 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
629
726
|
|
|
630
727
|
const remoteBashOps = (): BashOperations => ({
|
|
631
728
|
exec: (command, cwd, { onData, signal, timeout }) => new Promise((resolve, reject) => {
|
|
632
|
-
const timeoutSeconds = timeout
|
|
633
|
-
const remoteCommand = timeoutSeconds
|
|
634
|
-
? `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -lc ${quote(command)}`
|
|
635
|
-
: command;
|
|
729
|
+
const timeoutSeconds = parseRemoteTimeout(timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
|
|
730
|
+
const remoteCommand = withRemoteTimeout(command, timeoutSeconds);
|
|
636
731
|
const full = `cd -- ${quote(mapPath(cwd))} && ${remoteCommand}`;
|
|
637
732
|
void openExecChannel(full).then((stream) => {
|
|
638
733
|
let timedOut = false;
|
|
639
|
-
const timer =
|
|
734
|
+
const timer = setTimeout(() => { timedOut = true; stream.close(); }, (timeoutSeconds + 8) * 1000);
|
|
640
735
|
const abort = () => stream.close();
|
|
641
736
|
signal?.addEventListener("abort", abort, { once: true });
|
|
642
737
|
stream.on("data", onData);
|
|
643
738
|
stream.stderr.on("data", onData);
|
|
644
739
|
stream.on("close", (code: number | null) => {
|
|
645
|
-
|
|
740
|
+
clearTimeout(timer);
|
|
646
741
|
signal?.removeEventListener("abort", abort);
|
|
647
742
|
if (signal?.aborted) reject(new Error("aborted"));
|
|
648
|
-
else if (timedOut || code === 124) reject(new Error(`timeout:${timeoutSeconds}`));
|
|
743
|
+
else if (timedOut || code === 124 || code === 137) reject(new Error(`timeout:${timeoutSeconds}`));
|
|
649
744
|
else resolve({ exitCode: code });
|
|
650
745
|
});
|
|
651
746
|
}, reject);
|
|
@@ -664,24 +759,28 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
664
759
|
pi.registerTool({
|
|
665
760
|
name: "ssh_remote_control",
|
|
666
761
|
label: "SSH Remote Control",
|
|
667
|
-
description: "Connect, reconnect, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment.
|
|
668
|
-
promptSnippet: "Control the configured remote SSH connection, working directory, and local port forwarding",
|
|
762
|
+
description: "Connect, reconnect, annotate endpoints, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output uses a configurable collapsed preview (5 visual lines by default), while model output is limited to 50KB or 2000 lines and saved to a local temporary file when truncated. Passwords are never accepted as arguments and are cached only in process memory.",
|
|
763
|
+
promptSnippet: "Control the configured remote SSH connection, endpoint note, working directory, and local port forwarding",
|
|
669
764
|
promptGuidelines: [
|
|
670
765
|
"Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
671
766
|
"Use ssh_remote_control with action chdir when the user asks to change the remote working directory; do not emulate a persistent directory change with action exec and a one-command cwd.",
|
|
767
|
+
`Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
672
768
|
"Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
|
|
673
769
|
],
|
|
674
770
|
parameters: Type.Object({
|
|
675
|
-
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir"] as const),
|
|
676
|
-
command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22" })),
|
|
771
|
+
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note"] as const),
|
|
772
|
+
command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22; optionally selects the endpoint for note" })),
|
|
773
|
+
note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
|
|
677
774
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
678
775
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
679
776
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
777
|
+
timeout: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_REMOTE_TIMEOUT_SECONDS, description: `Remote command timeout in seconds; defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS}` })),
|
|
778
|
+
displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES, description: "Collapsed visual lines for exec output; defaults to the /remote config display-lines setting (5 initially)" })),
|
|
680
779
|
}),
|
|
681
780
|
async execute(_id, params, _signal, _update, ctx) {
|
|
682
781
|
if (params.action === "status") {
|
|
683
782
|
const mappings = [...forwardServers.keys()].sort((a, b) => a - b);
|
|
684
|
-
const text = `${remote ? `Connected: ${remote
|
|
783
|
+
const text = `${remote ? `Connected: ${endpointDisplayLabel(remote)}:${remote.cwd}; tool routing: ${routeRemoteTools ? "remote" : "local"}` : "SSH remote is disconnected"}${mappings.length ? `; forwarded local ports: ${mappings.join(", ")}` : ""}`;
|
|
685
784
|
return { content: [{ type: "text", text }], details: { connected: Boolean(remote), cwd: remote?.cwd, toolRouting: routeRemoteTools ? "remote" : "local", forwardedPorts: mappings } };
|
|
686
785
|
}
|
|
687
786
|
if (params.action === "disconnect" || params.action === "forget") {
|
|
@@ -691,7 +790,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
691
790
|
if (params.action === "reconnect") {
|
|
692
791
|
if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
|
|
693
792
|
const state = await reconnectRemote();
|
|
694
|
-
return { content: [{ type: "text", text: `Reconnected: ${state
|
|
793
|
+
return { content: [{ type: "text", text: `Reconnected: ${endpointDisplayLabel(state)}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
|
|
695
794
|
}
|
|
696
795
|
if (params.action === "unforward") {
|
|
697
796
|
await stopForwards();
|
|
@@ -714,6 +813,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
714
813
|
const resolved = await changeRemoteCwd(params.cwd, ctx);
|
|
715
814
|
return { content: [{ type: "text", text: `Remote working directory: ${resolved}` }], details: { connected: true, cwd: resolved } };
|
|
716
815
|
}
|
|
816
|
+
if (params.action === "note") {
|
|
817
|
+
const command = params.command || lastCommand || activeSshCommand();
|
|
818
|
+
if (!command) throw new Error("No SSH endpoint configured; connect or select an endpoint first");
|
|
819
|
+
const note = params.note?.trim() || undefined;
|
|
820
|
+
saveEndpointConfig(command, { note });
|
|
821
|
+
if (remote && cacheId(remote) === cacheId(parseSshCommand(command)) && currentCtx) status(currentCtx);
|
|
822
|
+
const label = parseSshCommand(command).label;
|
|
823
|
+
return {
|
|
824
|
+
content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
|
|
825
|
+
details: { endpoint: label, note },
|
|
826
|
+
};
|
|
827
|
+
}
|
|
717
828
|
if (params.action === "exec") {
|
|
718
829
|
const state = await ensureConnected(ctx);
|
|
719
830
|
if (!params.remoteCommand) throw new Error("remoteCommand is required for exec");
|
|
@@ -722,20 +833,41 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
722
833
|
const resolved = await changeRemoteCwd(cdTarget, ctx);
|
|
723
834
|
return { content: [{ type: "text", text: resolved }], details: { connected: true, cwd: resolved } };
|
|
724
835
|
}
|
|
725
|
-
const
|
|
836
|
+
const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
|
|
837
|
+
const timeoutSeconds = parseRemoteTimeout(params.timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
|
|
838
|
+
const output = await withReconnect((client) => execRemote(
|
|
839
|
+
client,
|
|
840
|
+
`cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`,
|
|
841
|
+
false,
|
|
842
|
+
timeoutSeconds,
|
|
843
|
+
));
|
|
726
844
|
const formatted = formatRemoteOutput(output.toString());
|
|
727
|
-
return {
|
|
845
|
+
return {
|
|
846
|
+
content: [{ type: "text", text: formatted.text }],
|
|
847
|
+
details: {
|
|
848
|
+
action: "exec",
|
|
849
|
+
connected: true,
|
|
850
|
+
cwd: state.cwd,
|
|
851
|
+
displayLines,
|
|
852
|
+
output: formatted.content,
|
|
853
|
+
truncation: formatted.truncation.truncated ? formatted.truncation : undefined,
|
|
854
|
+
fullOutputPath: formatted.fullOutputPath,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
728
857
|
}
|
|
729
858
|
const command = params.command || lastCommand || activeSshCommand();
|
|
730
859
|
if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
|
|
731
860
|
const state = await connectInteractive(command, ctx, params.cwd ?? configuredCwd(command));
|
|
732
861
|
if (!state) throw new Error(lastConnectionError || "SSH remote connection was cancelled or failed");
|
|
733
|
-
return { content: [{ type: "text", text: `Connected: ${state
|
|
862
|
+
return { content: [{ type: "text", text: `Connected: ${endpointDisplayLabel(state)}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
|
|
863
|
+
},
|
|
864
|
+
renderResult(result, { expanded }, theme) {
|
|
865
|
+
return renderRemoteControlResult(result, expanded, theme);
|
|
734
866
|
},
|
|
735
867
|
});
|
|
736
868
|
|
|
737
869
|
pi.registerCommand("remote", {
|
|
738
|
-
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | forward [MAPPINGS] | unforward | exec COMMAND | cd PATH | status | reload | off | forget",
|
|
870
|
+
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config note TEXT|--clear | config cwd PATH | config display-lines N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
739
871
|
handler: async (args, ctx) => {
|
|
740
872
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
741
873
|
const action = input.toLowerCase();
|
|
@@ -743,9 +875,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
743
875
|
const config = loadRemoteConfig();
|
|
744
876
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
745
877
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
746
|
-
return `${active} ${key}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
878
|
+
return `${active} ${key}\n note: ${endpoint.note || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
747
879
|
});
|
|
748
|
-
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
880
|
+
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
749
881
|
return;
|
|
750
882
|
}
|
|
751
883
|
if (/^ssh\s+/i.test(input)) {
|
|
@@ -784,6 +916,25 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
784
916
|
ctx.ui.notify(`Selected SSH remote endpoint: ${key}; use /remote to connect`, "info");
|
|
785
917
|
return;
|
|
786
918
|
}
|
|
919
|
+
if (/^config\s+note(?:\s+|$)/i.test(input)) {
|
|
920
|
+
const value = input.replace(/^config\s+note\s*/i, "").trim();
|
|
921
|
+
if (!value) { ctx.ui.notify("Use /remote config note TEXT or /remote config note --clear", "error"); return; }
|
|
922
|
+
const command = lastCommand || activeSshCommand();
|
|
923
|
+
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
924
|
+
const note = value.toLowerCase() === "--clear" ? undefined : value;
|
|
925
|
+
saveEndpointConfig(command, { note });
|
|
926
|
+
if (remote && cacheId(remote) === cacheId(parseSshCommand(command))) status(ctx);
|
|
927
|
+
ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (/^config\s+display-lines\s+/i.test(input)) {
|
|
931
|
+
try {
|
|
932
|
+
const displayLines = parseDisplayLines(input.replace(/^config\s+display-lines\s+/i, "").trim());
|
|
933
|
+
saveRemoteConfig({ ...loadRemoteConfig(), displayLines });
|
|
934
|
+
ctx.ui.notify(`SSH remote command preview updated: ${displayLines} lines`, "info");
|
|
935
|
+
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
787
938
|
if (/^config\s+cwd\s+/i.test(input)) {
|
|
788
939
|
const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
|
|
789
940
|
const command = lastCommand || activeSshCommand();
|
|
@@ -824,16 +975,36 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
824
975
|
if (/^exec\s+/i.test(input)) {
|
|
825
976
|
try {
|
|
826
977
|
const state = await ensureConnected(ctx);
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
978
|
+
let execInput = input.replace(/^exec\s+/i, "").trim();
|
|
979
|
+
let displayLines = configuredDisplayLines();
|
|
980
|
+
let timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS;
|
|
981
|
+
while (execInput.startsWith("--")) {
|
|
982
|
+
const option = execInput.match(/^--(lines|timeout)\s+(\S+)\s+([\s\S]+)$/i);
|
|
983
|
+
if (!option) throw new Error("Expected --lines N or --timeout SECONDS followed by a command");
|
|
984
|
+
if (option[1]!.toLowerCase() === "lines") displayLines = parseDisplayLines(option[2]);
|
|
985
|
+
else timeoutSeconds = parseRemoteTimeout(option[2]);
|
|
986
|
+
execInput = option[3]!;
|
|
987
|
+
}
|
|
988
|
+
const output = (await withReconnect((client) => execRemote(
|
|
989
|
+
client,
|
|
990
|
+
`cd -- ${quote(state.cwd)} && ${execInput}`,
|
|
991
|
+
false,
|
|
992
|
+
timeoutSeconds,
|
|
993
|
+
))).toString().trim();
|
|
994
|
+
const formatted = formatRemoteOutput(output);
|
|
995
|
+
const preview = previewRemoteOutput(formatted.content, displayLines);
|
|
996
|
+
const omitted = formatted.truncation.totalLines > displayLines
|
|
997
|
+
? `\n\n[Showing last ${Math.min(displayLines, formatted.truncation.totalLines)} of ${formatted.truncation.totalLines} lines]`
|
|
998
|
+
: "";
|
|
999
|
+
const fullOutput = formatted.fullOutputPath ? `\n[Full output: ${formatted.fullOutputPath}]` : "";
|
|
1000
|
+
ctx.ui.notify(`${preview}${omitted}${fullOutput}`, "info");
|
|
830
1001
|
} catch (error) { ctx.ui.notify(`SSH remote command failed: ${(error as Error).message}`, "error"); }
|
|
831
1002
|
return;
|
|
832
1003
|
}
|
|
833
1004
|
if (["off", "disconnect", "exit"].includes(action)) { disconnect(ctx); return; }
|
|
834
1005
|
if (action === "forget") { disconnect(ctx, true); return; }
|
|
835
1006
|
if (action === "status") {
|
|
836
|
-
ctx.ui.notify(remote ? `${remote
|
|
1007
|
+
ctx.ui.notify(remote ? `${endpointDisplayLabel(remote)}:${remote.cwd}` : "SSH remote is disconnected", "info");
|
|
837
1008
|
return;
|
|
838
1009
|
}
|
|
839
1010
|
if (["reload", "reconnect"].includes(action)) {
|
|
@@ -886,7 +1057,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
886
1057
|
pi.on("before_agent_start", (event) => remote && routeRemoteTools ? {
|
|
887
1058
|
systemPrompt: event.systemPrompt.replace(
|
|
888
1059
|
`Current working directory: ${localCwd}`,
|
|
889
|
-
`Current working directory: ${remote.cwd} (via SSH ${remote
|
|
1060
|
+
`Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use ssh_remote_control with action disconnect to return to the local environment when requested.`,
|
|
890
1061
|
),
|
|
891
1062
|
} : undefined);
|
|
892
1063
|
}
|