pi-ssh-remote 0.1.3 → 0.1.5

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 CHANGED
File without changes
package/README.md CHANGED
@@ -1,12 +1,56 @@
1
1
  <!--
2
- Concise English documentation for pi-ssh-remote, covering its purpose, installation, essential commands, authentication, and security boundaries.
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
- [中文](README.zh-CN.md)
7
+ **SSH designed for agents—not just for terminals.**
8
8
 
9
- Use Pi's file and shell tools on a persistent remote SSH workspace. Supports multiple endpoints, remote working directories, reconnection, and local port forwarding.
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, server-specific memory, 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. Server memory is automatically injected into the model context whenever that endpoint is connected as the active remote workspace.
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,32 +58,241 @@ 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
- ## Use
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 USER@HOST -p PORT # save and connect
21
- /remote cd /remote/project
66
+ /remote ssh root@gpu-box.example.com -p 2202
67
+ /remote cd /srv/project
68
+ /remote config note H100 training server
69
+ /remote config memory Use /opt/conda/bin/python and never stop shared jobs.
22
70
  /remote status
23
- /remote off # return to local tools
24
71
  ```
25
72
 
26
- Remote command previews follow Pi's local Bash behavior and show the last 5 visual lines by default. Configure the default or override one `/remote exec` invocation:
73
+ From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
74
+
75
+ ```text
76
+ SSH remote H100 training server (root@gpu-box.example.com:2202):/srv/project
77
+ ```
78
+
79
+ Return to local tools with:
80
+
81
+ ```text
82
+ /remote off
83
+ ```
84
+
85
+ ## Examples
86
+
87
+ ### 1. Let the agent investigate and repair a remote failure
88
+
89
+ ```text
90
+ Connect to ssh ubuntu@training.example.com -p 22 and work in /opt/app.
91
+ Inspect the failed deployment, trace the error through the logs and source,
92
+ make the smallest safe fix, run the relevant tests, and show me the diff.
93
+ ```
94
+
95
+ The connection is established once. Subsequent reads, searches, edits, and tests are ordinary Pi tool calls routed to the server.
96
+
97
+ ### 2. Launch a long GPU job without blocking the agent
98
+
99
+ ```text
100
+ On the current remote server, validate the training command first. Then launch
101
+ it with nohup in the background, redirect stdout and stderr from process start,
102
+ and report the PID, output directory, and log path. Verify that the process is
103
+ still alive and that the log has started.
104
+ ```
105
+
106
+ The default foreground timeout prevents an accidental long-running command from occupying the agent indefinitely, while an explicitly backgrounded job continues on the server.
107
+
108
+ ### 3. Keep several machines understandable
109
+
110
+ ```text
111
+ /remote ssh root@10.0.0.21 -p 22
112
+ /remote config note 8xH100 training
113
+ /remote config cwd /srv/train
114
+
115
+ /remote ssh ubuntu@staging.example.com -p 2222
116
+ /remote config note staging API
117
+ /remote config cwd /opt/service
118
+
119
+ /remote config
120
+ /remote use root@10.0.0.21:22
121
+ /remote
122
+ ```
123
+
124
+ Endpoint notes, working directories, and server-specific memories survive Pi restarts because they are stored in the local remote configuration. When Pi connects to an endpoint, its memory is added to the system context on every agent run while remote tool routing remains active. Disconnecting or switching to tunnel-only mode removes it from subsequent model requests.
125
+
126
+ ### 4. Expose a remote service while editing locally
127
+
128
+ ```text
129
+ /remote config forward 7860:127.0.0.1:7860
130
+ /remote forward
131
+ ```
132
+
133
+ 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.
134
+
135
+ Stop the tunnels with:
136
+
137
+ ```text
138
+ /remote unforward
139
+ ```
140
+
141
+ ### 5. Ask Pi directly
142
+
143
+ ```text
144
+ Connect to my configured remote, switch to /srv/api, and inspect its Git status.
145
+ ```
146
+
147
+ ```text
148
+ Add the note "production read-only" to this endpoint and tell me which remote
149
+ workspace is active.
150
+ ```
151
+
152
+ ```text
153
+ Forward the remote service on port 8000 to localhost:8000, but keep my coding
154
+ tools on the local repository.
155
+ ```
156
+
157
+ These requests are handled through the agent-facing `ssh_remote_control` tool; slash commands are optional.
158
+
159
+ ## Command reference
160
+
161
+ | Command | Purpose |
162
+ |---|---|
163
+ | `/remote ssh USER@HOST -p PORT` | Save, select, and connect to an endpoint |
164
+ | `/remote` | Connect to the selected endpoint or prompt for one |
165
+ | `/remote config` | List saved endpoints and settings |
166
+ | `/remote use USER@HOST:PORT` | Select a saved endpoint |
167
+ | `/remote config note TEXT` | Persist a note for the selected endpoint |
168
+ | `/remote config note --clear` | Clear its note |
169
+ | `/remote config memory TEXT` | Persist context that is injected while working on this endpoint |
170
+ | `/remote config memory --clear` | Clear its server-specific memory |
171
+ | `/remote config cwd PATH` | Persist its default remote working directory |
172
+ | `/remote cd PATH` | Change the connected remote cwd and persist it |
173
+ | `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
174
+ | `/remote forward [MAPPING...]` | Start tunnels and keep Pi's tools local |
175
+ | `/remote unforward` | Stop extension-managed tunnels |
176
+ | `/remote exec COMMAND` | Run one command in the remote cwd |
177
+ | `/remote exec --timeout 60 COMMAND` | Override the command timeout |
178
+ | `/remote exec --lines 20 COMMAND` | Override collapsed preview lines |
179
+ | `/remote config display-lines 10` | Set default collapsed preview lines |
180
+ | `/remote status` | Show the active workspace |
181
+ | `/remote reload` | Reconnect the active workspace |
182
+ | `/remote off` | Disconnect and return tools to local execution |
183
+ | `/remote forget` | Disconnect and clear the in-memory password |
184
+
185
+ ## Persistence, output, and security
186
+
187
+ Endpoint configuration is stored locally in:
27
188
 
28
189
  ```text
29
- /remote config display-lines 10
30
- /remote exec --lines 20 COMMAND
190
+ ~/.pi/agent/ssh-remote-config.json
31
191
  ```
32
192
 
33
- The `ssh_remote_control` tool's `exec` action also accepts `displayLines`. Preview settings affect only the collapsed UI; model output keeps Pi's 2000-line/50KB safety limits, with oversized output saved to a temporary file.
193
+ Saved values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, and preview settings. Server memory is user-configured trusted context and is inserted into each model request only while that endpoint is the active remote workspace; it is not read from the remote server. Passwords are **never written to this file**: SSH agent authentication is preferred, and prompted passwords remain only in process memory.
194
+
195
+ 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.
196
+
197
+ ## Current SSH scope
198
+
199
+ The extension currently supports direct SSH commands with `-p` and `-l`. It does not yet consume `~/.ssh/config`, `IdentityFile`, or ProxyJump settings.
200
+
201
+ ## Release history
202
+
203
+ Release dates below are npm publication dates. Every release records user-visible additions, changes, and bug fixes; `None` means that category had no changes in that release.
204
+
205
+ ### 0.1.5 — 2026-08-04
206
+
207
+ **Added**
208
+
209
+ - Added persistent, endpoint-specific server memory through `/remote config memory TEXT` and the `ssh_remote_control` `memory` action.
210
+ - Added automatic injection of that memory into every model request while the matching endpoint is the active remote workspace.
211
+
212
+ **Changed**
213
+
214
+ - Server memory is removed from subsequent model requests when remote tool routing is disabled, including disconnect and tunnel-only mode.
215
+ - `/remote config` now shows the memory stored for each endpoint.
216
+
217
+ **Fixed**
218
+
219
+ - None.
220
+
221
+ ### 0.1.4 — 2026-07-31
222
+
223
+ **Added**
224
+
225
+ - Added persistent endpoint notes through `/remote config note TEXT` and the `ssh_remote_control` `note` action.
226
+ - Added per-command timeout overrides through `/remote exec --timeout SECONDS` and the tool's `timeout` parameter.
227
+
228
+ **Changed**
229
+
230
+ - Remote commands now default to a 30-second timeout and endpoint notes appear in status, connection, reconnection, and footer labels.
231
+ - Expanded the documentation around agent-first workflows, multi-server operation, background jobs, and tunnel mode.
232
+
233
+ **Fixed**
234
+
235
+ - Fixed remote commands that could run without a deadline by enforcing GNU `timeout` remotely and a local fallback timer.
236
+ - Fixed timeout reporting for processes terminated with timeout-related exit codes 124 and 137.
237
+
238
+ ### 0.1.3 — 2026-07-24
239
+
240
+ **Added**
241
+
242
+ - Added configurable collapsed command previews with `/remote config display-lines N`, `/remote exec --lines N`, and the tool's `displayLines` parameter.
243
+ - Added expandable custom rendering for remote command results.
244
+
245
+ **Changed**
246
+
247
+ - Remote command previews default to the last five visual lines while model-facing output remains limited to 2,000 lines or 50 KB.
248
+ - Oversized complete output is saved to a permission-restricted local temporary file.
249
+
250
+ **Fixed**
251
+
252
+ - Fixed `/remote exec` truncating its notification to an arbitrary 4,000 characters without preserving or identifying the full output.
253
+
254
+ ### 0.1.2 — 2026-07-24
255
+
256
+ **Added**
257
+
258
+ - None.
259
+
260
+ **Changed**
261
+
262
+ - Republished the 0.1.1 runtime as 0.1.2 to align npm package version metadata; there were no runtime code changes.
263
+
264
+ **Fixed**
265
+
266
+ - None.
267
+
268
+ ### 0.1.1 — 2026-07-24
269
+
270
+ **Added**
271
+
272
+ - Added the shorter `/remote ssh USER@HOST [-p PORT]` connection command.
273
+
274
+ **Changed**
275
+
276
+ - `/remote ssh` now saves, selects, and immediately connects to the endpoint instead of only storing it.
277
+
278
+ **Fixed**
279
+
280
+ - Fixed help and error text that still instructed users to run the obsolete `/remote config ssh ssh ...` form.
281
+
282
+ ### 0.1.0 — 2026-07-24
283
+
284
+ **Added**
34
285
 
35
- Run `/remote config` to list endpoints and `/remote` to see all available subcommands.
286
+ - Initial release with persistent multi-endpoint SSH workspaces and remote working directories.
287
+ - Added transparent routing for Pi's `read`, `write`, `edit`, `bash`, and user shell operations.
288
+ - Added SSH-agent/password authentication, interactive host-key verification, automatic reconnection, SFTP file access, bounded command output, and local TCP forwarding.
36
289
 
37
- ## Authentication and security
290
+ **Changed**
38
291
 
39
- Uses SSH agent authentication when available, otherwise prompts for a password. Passwords stay in process memory. New or changed host keys require confirmation and are stored separately from OpenSSH.
292
+ - None.
40
293
 
41
- Only direct SSH commands with `-p` and `-l` are supported. `~/.ssh/config`, `IdentityFile`, and ProxyJump are not currently supported.
294
+ **Fixed**
42
295
 
43
- Requires Node.js 20+, Bash, SFTP, and GNU `timeout` on the remote host.
296
+ - None.
44
297
 
45
298
  MIT licensed.
package/README.zh-CN.md CHANGED
@@ -1,12 +1,90 @@
1
1
  <!--
2
- pi-ssh-remote 的简明中文文档,说明扩展用途、安装方式、必要命令、认证机制和安全边界。
2
+ pi-ssh-remote 中文说明文档,重点介绍它面向 Pi Agent 的设计、主要能力、常见使用场景、命令和安全机制。
3
3
  -->
4
4
 
5
5
  # pi-ssh-remote
6
6
 
7
- [English](README.md)
7
+ **让 Pi 像操作本地项目一样操作远程服务器。**
8
8
 
9
- Pi 的文件与 Shell 工具在持久化 SSH 远程工作区中运行。支持多个服务器、远程工作目录、自动重连和本地端口转发。
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
+
57
+ 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。还可以为每个 `user@host:port` 保存独立记忆,例如指定 Python 环境、共享任务保护规则或部署约定。连接该 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
58
+
59
+ ### 断线后可以自动恢复
60
+
61
+ 当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。
62
+
63
+ ### 对 Agent 上下文更友好
64
+
65
+ 远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent。命令输出过大时,只会把受限内容放入模型上下文,完整输出会另外保存到临时文件,方便后续继续检查。
66
+
67
+ ### 可以远端跑服务、本地改代码
68
+
69
+ 端口转发模式下,可以把远端服务映射到 `localhost`,同时让 Pi 的文件和 Shell 工具继续操作本地项目。
70
+
71
+ 这很适合下面这类场景:
72
+
73
+ - GPU 服务器运行模型,本地开发 Web UI;
74
+ - 远端启动 API,本地调试客户端;
75
+ - 远端运行训练监控,本地查看页面;
76
+ - 内网服务通过 SSH 隧道提供给本地工具使用。
77
+
78
+ ## 和直接使用 SSH 有什么区别
79
+
80
+ | 直接执行 SSH | 使用 `pi-ssh-remote` |
81
+ |---|---|
82
+ | 每条命令都要重新拼接 SSH | 连接一次后,Pi 工具自动在远端执行 |
83
+ | 多次调用之间容易丢失目录 | 自动记住远程工作目录 |
84
+ | Agent 需要自己判断当前在哪台机器 | 系统上下文和底部状态栏会显示当前服务器 |
85
+ | 连接中断后需要手动恢复 | 当前会话内自动重连 |
86
+ | 文件修改、命令执行和端口转发各自处理 | 统一通过 `/remote` 和 Agent 工具管理 |
87
+ | 大量输出可能直接占满模型上下文 | 内置超时、折叠预览和输出上限 |
10
88
 
11
89
  ## 安装
12
90
 
@@ -14,32 +92,280 @@ pi-ssh-remote 的简明中文文档,说明扩展用途、安装方式、必要
14
92
  pi install npm:pi-ssh-remote
15
93
  ```
16
94
 
17
- ## 使用
95
+ 本地要求 Node.js 20+。远程服务器需要提供 Bash、SFTP 和 GNU `timeout`。
96
+
97
+ ## 快速开始
98
+
99
+ 连接服务器:
100
+
101
+ ```text
102
+ /remote ssh root@gpu-box.example.com -p 2202
103
+ ```
104
+
105
+ 设置远程工作目录和备注:
106
+
107
+ ```text
108
+ /remote cd /srv/project
109
+ /remote config note H100 训练机
110
+ /remote config memory 使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
111
+ ```
112
+
113
+ 查看当前状态:
18
114
 
19
115
  ```text
20
- /remote ssh USER@HOST -p PORT # 保存并连接
21
- /remote cd /remote/project
22
116
  /remote status
23
- /remote off # 返回本地工具
24
117
  ```
25
118
 
26
- 远程命令预览与 Pi 本地 Bash 一致,默认展示最后 5 个视觉行。可以修改默认值,或只覆盖某一次 `/remote exec`:
119
+ 连接成功后,Pi 的文件和 Shell 工具都会操作远程 `/srv/project`。
120
+
121
+ 需要返回本地时执行:
122
+
123
+ ```text
124
+ /remote off
125
+ ```
126
+
127
+ ## 常见用法
128
+
129
+ ### 1. 让 Pi 直接排查远程服务故障
130
+
131
+ ```text
132
+ 连接 ubuntu@training.example.com,进入 /opt/app。
133
+ 先检查部署日志和 Git 状态,找出失败原因。
134
+ 如果需要修改代码,先说明原因,再做最小修改并运行相关测试,
135
+ 最后把 diff 和测试结果发给我。
136
+ ```
137
+
138
+ 连接完成后,Pi 后续的读文件、查日志、改代码和跑测试都会直接在服务器上进行。
139
+
140
+ ### 2. 启动长时间训练任务
141
+
142
+ ```text
143
+ 在当前远程服务器检查训练命令和配置。
144
+ 确认无误后用 nohup 在后台启动,并从一开始就重定向 stdout 和 stderr。
145
+ 把 PID、输出目录和日志路径发给我,再检查一次进程是否仍在运行、日志是否已经开始写入。
146
+ ```
147
+
148
+ 插件默认限制前台命令的执行时间,但通过 `nohup` 等方式启动的后台任务可以继续在服务器运行。
149
+
150
+ ### 3. 管理多台服务器
151
+
152
+ 先配置训练机:
153
+
154
+ ```text
155
+ /remote ssh root@10.0.0.21 -p 22
156
+ /remote config note 8xH100 训练机
157
+ /remote config cwd /srv/train
158
+ ```
159
+
160
+ 再配置预发布服务器:
161
+
162
+ ```text
163
+ /remote ssh ubuntu@staging.example.com -p 2222
164
+ /remote config note 预发布 API
165
+ /remote config cwd /opt/service
166
+ ```
167
+
168
+ 查看并切换服务器:
27
169
 
28
170
  ```text
29
- /remote config display-lines 10
30
- /remote exec --lines 20 COMMAND
171
+ /remote config
172
+ /remote use root@10.0.0.21:22
173
+ /remote
31
174
  ```
32
175
 
33
- `ssh_remote_control` 工具的 `exec` 操作也支持 `displayLines`。预览设置只影响折叠界面;提供给模型的输出仍采用 Pi 的 2000 行/50KB 安全限制,超限完整输出会保存到临时文件。
176
+ 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
177
+
178
+ ### 4. 远端启动模型,本地开发界面
179
+
180
+ 保存并启动端口转发:
181
+
182
+ ```text
183
+ /remote config forward 7860:127.0.0.1:7860
184
+ /remote forward
185
+ ```
186
+
187
+ 现在访问本地 `localhost:7860`,实际连接的是远程服务器上的 7860 端口;与此同时,Pi 的文件和 Shell 工具会留在本地,方便继续修改前端或客户端代码。
188
+
189
+ 停止转发:
190
+
191
+ ```text
192
+ /remote unforward
193
+ ```
194
+
195
+ ### 5. 直接用自然语言操作
196
+
197
+ 不想记命令时,可以直接告诉 Pi:
198
+
199
+ ```text
200
+ 连接已配置的远程服务器,进入 /srv/api,然后检查 Git 状态。
201
+ ```
202
+
203
+ ```text
204
+ 把当前服务器备注为“线上只读机”,然后告诉我现在操作的是哪台服务器。
205
+ ```
206
+
207
+ ```text
208
+ 把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
209
+ ```
210
+
211
+ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
212
+
213
+ ## 命令说明
214
+
215
+ | 命令 | 作用 |
216
+ |---|---|
217
+ | `/remote ssh USER@HOST -p PORT` | 保存并连接服务器 |
218
+ | `/remote` | 连接当前选中的服务器,或提示输入 SSH 地址 |
219
+ | `/remote config` | 查看已保存的服务器和配置 |
220
+ | `/remote use USER@HOST:PORT` | 切换到指定服务器 |
221
+ | `/remote config note TEXT` | 给当前服务器添加或修改备注 |
222
+ | `/remote config note --clear` | 清除当前服务器备注 |
223
+ | `/remote config memory TEXT` | 保存连接该服务器时自动注入上下文的记忆 |
224
+ | `/remote config memory --clear` | 清除当前服务器的特定记忆 |
225
+ | `/remote config cwd PATH` | 设置默认远程工作目录 |
226
+ | `/remote cd PATH` | 切换当前远程目录并保存 |
227
+ | `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
228
+ | `/remote forward [MAPPING...]` | 启动端口转发,并让 Pi 工具留在本地 |
229
+ | `/remote unforward` | 停止插件创建的端口转发 |
230
+ | `/remote exec COMMAND` | 在当前远程目录执行一次命令 |
231
+ | `/remote exec --timeout 60 COMMAND` | 单独设置本次命令的超时时间 |
232
+ | `/remote exec --lines 20 COMMAND` | 单独设置本次折叠显示的行数 |
233
+ | `/remote config display-lines 10` | 设置默认折叠显示行数 |
234
+ | `/remote status` | 查看当前连接和工作目录 |
235
+ | `/remote reload` | 重新连接当前服务器 |
236
+ | `/remote off` | 断开连接并返回本地 |
237
+ | `/remote forget` | 断开连接并清除内存中的密码 |
238
+
239
+ ## 配置保存在哪里
240
+
241
+ 服务器配置保存在本地:
242
+
243
+ ```text
244
+ ~/.pi/agent/ssh-remote-config.json
245
+ ```
246
+
247
+ 其中包括:
248
+
249
+ - 已保存的服务器;
250
+ - 当前选中的服务器;
251
+ - 服务器备注;
252
+ - 服务器特定记忆;
253
+ - 默认远程目录;
254
+ - 端口转发配置;
255
+ - 命令预览设置。
256
+
257
+ 服务器记忆是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当对应 endpoint 作为远端工作区启用时才会加入每次模型请求。
258
+
259
+ 密码不会写入配置文件。插件会优先使用 SSH agent;如果需要手动输入密码,密码只会缓存在当前 Pi 进程的内存中。
260
+
261
+ ## 安全与输出限制
262
+
263
+ - 第一次连接新服务器时,需要确认主机密钥;
264
+ - 主机密钥发生变化时,会再次要求确认;
265
+ - 远程命令默认 30 秒超时;
266
+ - 发送给模型的命令输出最多为 2,000 行或 50 KB;
267
+ - 超限的完整输出会保存到本地临时文件;
268
+ - 折叠显示行数只影响界面,不影响模型输出上限。
269
+
270
+ ## 当前限制
271
+
272
+ 目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config`,也不支持 `IdentityFile` 和 ProxyJump。
273
+
274
+ ## 版本更新记录
275
+
276
+ 以下日期均为 npm 发布时间。每次发布都记录用户可见的新增内容、行为变更和 Bug 修复;“无”表示该版本在对应类别没有变化。
277
+
278
+ ### 0.1.5 — 2026-08-04
279
+
280
+ **新增**
281
+
282
+ - 新增 endpoint 级持久化服务器记忆,支持 `/remote config memory TEXT` 和 `ssh_remote_control` 的 `memory` action。
283
+ - 对应 endpoint 作为当前远程工作区时,在每次模型请求中自动注入服务器记忆。
284
+
285
+ **变更**
286
+
287
+ - 断开连接、关闭远端工具路由或进入纯隧道模式后,后续模型请求不再包含服务器记忆。
288
+ - `/remote config` 现在会显示每个 endpoint 保存的记忆。
289
+
290
+ **修复**
291
+
292
+ - 无。
293
+
294
+ ### 0.1.4 — 2026-07-31
295
+
296
+ **新增**
297
+
298
+ - 新增 endpoint 备注,支持 `/remote config note TEXT` 和 `ssh_remote_control` 的 `note` action。
299
+ - 新增单次命令超时覆盖,支持 `/remote exec --timeout SECONDS` 和工具的 `timeout` 参数。
300
+
301
+ **变更**
302
+
303
+ - 远程命令默认超时改为 30 秒;备注会显示在状态、连接、重连提示和底部状态栏中。
304
+ - 扩充了 Agent 工作流、多服务器、后台任务和纯隧道模式文档。
305
+
306
+ **修复**
307
+
308
+ - 修复部分远程命令没有截止时间、可能一直占用 Agent 的问题:远端使用 GNU `timeout`,本地增加兜底定时器。
309
+ - 修复退出码 124 和 137 未被统一识别为超时的问题。
310
+
311
+ ### 0.1.3 — 2026-07-24
312
+
313
+ **新增**
314
+
315
+ - 新增可配置的命令折叠预览,支持 `/remote config display-lines N`、`/remote exec --lines N` 和工具的 `displayLines` 参数。
316
+ - 新增远程命令结果的展开显示。
317
+
318
+ **变更**
319
+
320
+ - 命令折叠预览默认显示最后 5 个视觉行;发送给模型的输出仍限制为 2,000 行或 50 KB。
321
+ - 超限的完整输出会保存到权限受限的本地临时文件。
322
+
323
+ **修复**
324
+
325
+ - 修复 `/remote exec` 直接按 4,000 字符截断通知、且不保留或提示完整输出位置的问题。
326
+
327
+ ### 0.1.2 — 2026-07-24
328
+
329
+ **新增**
330
+
331
+ - 无。
332
+
333
+ **变更**
334
+
335
+ - 将 0.1.1 的运行时代码以 0.1.2 重新发布,用于对齐 npm 包版本元数据;没有运行时代码变化。
336
+
337
+ **修复**
338
+
339
+ - 无。
340
+
341
+ ### 0.1.1 — 2026-07-24
342
+
343
+ **新增**
344
+
345
+ - 新增简化命令 `/remote ssh USER@HOST [-p PORT]`。
346
+
347
+ **变更**
348
+
349
+ - `/remote ssh` 现在会保存、选中并立即连接 endpoint,而不只是保存配置。
350
+
351
+ **修复**
352
+
353
+ - 修复帮助和错误信息仍提示使用旧命令 `/remote config ssh ssh ...` 的问题。
354
+
355
+ ### 0.1.0 — 2026-07-24
356
+
357
+ **新增**
34
358
 
35
- 使用 `/remote config` 查看服务器,使用 `/remote` 查看全部子命令。
359
+ - 首次发布,支持持久化多 endpoint SSH 工作区和远程工作目录。
360
+ - 新增 Pi `read`、`write`、`edit`、`bash` 和用户 Shell 操作的透明远端路由。
361
+ - 新增 SSH agent/密码认证、交互式主机密钥校验、自动重连、SFTP 文件访问、命令输出限制和本地 TCP 端口转发。
36
362
 
37
- ## 认证与安全
363
+ **变更**
38
364
 
39
- 优先使用 SSH agent,否则提示输入密码。密码仅缓存在进程内存中。首次连接或主机密钥变化时必须确认;主机密钥独立于 OpenSSH 存储。
365
+ - 无。
40
366
 
41
- 目前仅支持带 `-p` 和 `-l` 的直连 SSH 命令,暂不支持 `~/.ssh/config`、`IdentityFile` 和 ProxyJump。
367
+ **修复**
42
368
 
43
- 要求 Node.js 20+;远程服务器需提供 Bash、SFTP 和 GNU `timeout`。
369
+ - 无。
44
370
 
45
- 采用 MIT 许可证。
371
+ MIT License。
package/index.ts CHANGED
@@ -54,6 +54,8 @@ interface RemoteEndpointConfig {
54
54
  sshCommand?: string;
55
55
  remoteCwd?: string;
56
56
  forwards?: string[];
57
+ note?: string;
58
+ memory?: string;
57
59
  }
58
60
 
59
61
  interface RemoteConfig {
@@ -77,6 +79,8 @@ const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
77
79
  const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
78
80
  const FALLBACK_REMOTE_CWD = "~";
79
81
  const DEFAULT_DISPLAY_LINES = 5;
82
+ const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
83
+ const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
80
84
  const CACHE_KEY = "__piHpcCredentialCacheV1";
81
85
  const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
82
86
  const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
@@ -162,6 +166,18 @@ function parseDisplayLines(value: unknown): number {
162
166
  return lines;
163
167
  }
164
168
 
169
+ function parseRemoteTimeout(value: unknown): number {
170
+ const seconds = typeof value === "number" ? value : Number(value);
171
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_REMOTE_TIMEOUT_SECONDS) {
172
+ throw new Error(`Timeout must be a positive number no greater than ${MAX_REMOTE_TIMEOUT_SECONDS} seconds`);
173
+ }
174
+ return seconds;
175
+ }
176
+
177
+ function withRemoteTimeout(command: string, timeoutSeconds: number): string {
178
+ return `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -lc ${quote(command)}`;
179
+ }
180
+
165
181
  function configuredDisplayLines(config = loadRemoteConfig()): number {
166
182
  try { return parseDisplayLines(config.displayLines ?? DEFAULT_DISPLAY_LINES); }
167
183
  catch { return DEFAULT_DISPLAY_LINES; }
@@ -232,6 +248,28 @@ function activeSshCommand(config = loadRemoteConfig()): string | undefined {
232
248
  return activeEndpointConfig(config)?.sshCommand;
233
249
  }
234
250
 
251
+ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()): string {
252
+ const note = endpointConfig(config, endpoint.command).note?.trim();
253
+ return note ? `${note} (${endpoint.label})` : endpoint.label;
254
+ }
255
+
256
+ function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
257
+ return endpointConfig(config, endpoint.command).memory?.trim() || undefined;
258
+ }
259
+
260
+ function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
261
+ return systemPrompt.replace(
262
+ `Current working directory: ${localCwd}`,
263
+ `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.`,
264
+ );
265
+ }
266
+
267
+ function serverMemoryContext(remote: RemoteState): string | undefined {
268
+ const memory = endpointMemory(remote);
269
+ if (!memory) return undefined;
270
+ return `<ssh_remote_server_memory endpoint="${remote.label}">\nThe following is user-configured, persistent memory specific to this SSH server. Apply it while working on this server:\n${memory}\n</ssh_remote_server_memory>`;
271
+ }
272
+
235
273
  function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
236
274
  const config = loadRemoteConfig();
237
275
  const key = cacheId(parseSshCommand(command));
@@ -357,16 +395,34 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
357
395
  });
358
396
  }
359
397
 
360
- function execRemote(client: Client, command: string, allowFailure = false): Promise<Buffer> {
398
+ function execRemote(
399
+ client: Client,
400
+ command: string,
401
+ allowFailure = false,
402
+ timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
403
+ ): Promise<Buffer> {
404
+ const resolvedTimeout = parseRemoteTimeout(timeoutSeconds);
361
405
  return new Promise((resolve, reject) => {
362
- client.exec(command, (error, stream) => {
406
+ client.exec(withRemoteTimeout(command, resolvedTimeout), (error, stream) => {
363
407
  if (error) return reject(error);
364
408
  const stdout: Buffer[] = [];
365
409
  const stderr: Buffer[] = [];
410
+ let locallyTimedOut = false;
411
+ const timer = setTimeout(() => {
412
+ locallyTimedOut = true;
413
+ stream.close();
414
+ }, (resolvedTimeout + 8) * 1000);
415
+ const cleanup = () => clearTimeout(timer);
366
416
  stream.on("data", (chunk: Buffer) => stdout.push(chunk));
367
417
  stream.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
418
+ stream.once("error", (streamError: Error) => {
419
+ cleanup();
420
+ reject(streamError);
421
+ });
368
422
  stream.on("close", (code: number | null) => {
369
- if (!allowFailure && code !== 0) reject(new Error(Buffer.concat(stderr).toString().trim() || `Remote command exited with code ${code}`));
423
+ cleanup();
424
+ if (locallyTimedOut || code === 124 || code === 137) reject(new Error(`Remote command timed out after ${resolvedTimeout} seconds`));
425
+ else if (!allowFailure && code !== 0) reject(new Error(Buffer.concat(stderr).toString().trim() || `Remote command exited with code ${code}`));
370
426
  else resolve(Buffer.concat(stdout));
371
427
  });
372
428
  });
@@ -442,8 +498,8 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
442
498
  const status = (ctx: any) => {
443
499
  currentCtx = ctx;
444
500
  if (!remote) ctx.ui.setStatus("ssh-remote", undefined);
445
- else if (routeRemoteTools) ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote ${remote.label}:${remote.cwd}`));
446
- else ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote tunnel ${[...forwardServers.keys()].join(",") || remote.label}`));
501
+ else if (routeRemoteTools) ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote ${endpointDisplayLabel(remote)}:${remote.cwd}`));
502
+ else ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote tunnel ${[...forwardServers.keys()].join(",") || endpointDisplayLabel(remote)}`));
447
503
  };
448
504
 
449
505
  const attachClient = (state: RemoteState) => {
@@ -451,7 +507,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
451
507
  client.on("close", () => {
452
508
  if (remote?.client !== client) return;
453
509
  if (currentCtx) {
454
- currentCtx.ui.setStatus("ssh-remote", currentCtx.ui.theme.fg("warning", `SSH remote reconnecting ${state.label}…`));
510
+ currentCtx.ui.setStatus("ssh-remote", currentCtx.ui.theme.fg("warning", `SSH remote reconnecting ${endpointDisplayLabel(state)}…`));
455
511
  }
456
512
  void reconnectRemote().catch((error) => {
457
513
  if (currentCtx) currentCtx.ui.notify(`SSH remote automatic reconnection failed: ${(error as Error).message}`, "error");
@@ -490,7 +546,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
490
546
  oldClient?.end();
491
547
  if (currentCtx) {
492
548
  status(currentCtx);
493
- currentCtx.ui.notify(`SSH remote reconnected automatically: ${next.label}:${next.cwd}`, "info");
549
+ currentCtx.ui.notify(`SSH remote reconnected automatically: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
494
550
  }
495
551
  return next;
496
552
  })().finally(() => { reconnectPromise = null; });
@@ -560,7 +616,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
560
616
  }
561
617
 
562
618
  let password = getCachedPassword(parsed);
563
- ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${parsed.label}…`));
619
+ ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
564
620
  try {
565
621
  let next: RemoteState;
566
622
  try {
@@ -585,7 +641,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
585
641
  lastConnectionError = undefined;
586
642
  saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
587
643
  status(ctx);
588
- ctx.ui.notify(`SSH remote connected: ${parsed.label}:${next.cwd}`, "info");
644
+ ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
589
645
  return next;
590
646
  } catch (error) {
591
647
  deleteCachedPassword(parsed);
@@ -688,23 +744,21 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
688
744
 
689
745
  const remoteBashOps = (): BashOperations => ({
690
746
  exec: (command, cwd, { onData, signal, timeout }) => new Promise((resolve, reject) => {
691
- const timeoutSeconds = timeout ? Math.max(1, Math.ceil(timeout)) : undefined;
692
- const remoteCommand = timeoutSeconds
693
- ? `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -lc ${quote(command)}`
694
- : command;
747
+ const timeoutSeconds = parseRemoteTimeout(timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
748
+ const remoteCommand = withRemoteTimeout(command, timeoutSeconds);
695
749
  const full = `cd -- ${quote(mapPath(cwd))} && ${remoteCommand}`;
696
750
  void openExecChannel(full).then((stream) => {
697
751
  let timedOut = false;
698
- const timer = timeoutSeconds ? setTimeout(() => { timedOut = true; stream.close(); }, (timeoutSeconds + 8) * 1000) : undefined;
752
+ const timer = setTimeout(() => { timedOut = true; stream.close(); }, (timeoutSeconds + 8) * 1000);
699
753
  const abort = () => stream.close();
700
754
  signal?.addEventListener("abort", abort, { once: true });
701
755
  stream.on("data", onData);
702
756
  stream.stderr.on("data", onData);
703
757
  stream.on("close", (code: number | null) => {
704
- if (timer) clearTimeout(timer);
758
+ clearTimeout(timer);
705
759
  signal?.removeEventListener("abort", abort);
706
760
  if (signal?.aborted) reject(new Error("aborted"));
707
- else if (timedOut || code === 124) reject(new Error(`timeout:${timeoutSeconds}`));
761
+ else if (timedOut || code === 124 || code === 137) reject(new Error(`timeout:${timeoutSeconds}`));
708
762
  else resolve({ exitCode: code });
709
763
  });
710
764
  }, reject);
@@ -723,25 +777,29 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
723
777
  pi.registerTool({
724
778
  name: "ssh_remote_control",
725
779
  label: "SSH Remote Control",
726
- description: "Connect, reconnect, 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.",
727
- promptSnippet: "Control the configured remote SSH connection, working directory, and local port forwarding",
780
+ description: "Connect, reconnect, annotate endpoints, manage server-specific memory, 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.",
781
+ promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
728
782
  promptGuidelines: [
729
783
  "Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
730
784
  "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.",
785
+ `Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
731
786
  "Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
732
787
  ],
733
788
  parameters: Type.Object({
734
- action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir"] as const),
735
- command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22" })),
789
+ action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
790
+ command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22; optionally selects the endpoint for note or memory" })),
791
+ note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
792
+ memory: Type.Optional(Type.String({ description: "Persistent server-specific context for the memory action; omit or use an empty string to clear it" })),
736
793
  cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
737
794
  forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
738
795
  remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
796
+ timeout: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_REMOTE_TIMEOUT_SECONDS, description: `Remote command timeout in seconds; defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS}` })),
739
797
  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)" })),
740
798
  }),
741
799
  async execute(_id, params, _signal, _update, ctx) {
742
800
  if (params.action === "status") {
743
801
  const mappings = [...forwardServers.keys()].sort((a, b) => a - b);
744
- const text = `${remote ? `Connected: ${remote.label}:${remote.cwd}; tool routing: ${routeRemoteTools ? "remote" : "local"}` : "SSH remote is disconnected"}${mappings.length ? `; forwarded local ports: ${mappings.join(", ")}` : ""}`;
802
+ const text = `${remote ? `Connected: ${endpointDisplayLabel(remote)}:${remote.cwd}; tool routing: ${routeRemoteTools ? "remote" : "local"}` : "SSH remote is disconnected"}${mappings.length ? `; forwarded local ports: ${mappings.join(", ")}` : ""}`;
745
803
  return { content: [{ type: "text", text }], details: { connected: Boolean(remote), cwd: remote?.cwd, toolRouting: routeRemoteTools ? "remote" : "local", forwardedPorts: mappings } };
746
804
  }
747
805
  if (params.action === "disconnect" || params.action === "forget") {
@@ -751,7 +809,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
751
809
  if (params.action === "reconnect") {
752
810
  if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
753
811
  const state = await reconnectRemote();
754
- return { content: [{ type: "text", text: `Reconnected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
812
+ return { content: [{ type: "text", text: `Reconnected: ${endpointDisplayLabel(state)}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
755
813
  }
756
814
  if (params.action === "unforward") {
757
815
  await stopForwards();
@@ -774,6 +832,26 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
774
832
  const resolved = await changeRemoteCwd(params.cwd, ctx);
775
833
  return { content: [{ type: "text", text: `Remote working directory: ${resolved}` }], details: { connected: true, cwd: resolved } };
776
834
  }
835
+ if (params.action === "note" || params.action === "memory") {
836
+ const command = params.command || lastCommand || activeSshCommand();
837
+ if (!command) throw new Error("No SSH endpoint configured; connect or select an endpoint first");
838
+ const label = parseSshCommand(command).label;
839
+ if (params.action === "note") {
840
+ const note = params.note?.trim() || undefined;
841
+ saveEndpointConfig(command, { note });
842
+ if (remote && cacheId(remote) === cacheId(parseSshCommand(command)) && currentCtx) status(currentCtx);
843
+ return {
844
+ content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
845
+ details: { endpoint: label, note },
846
+ };
847
+ }
848
+ const memory = params.memory?.trim() || undefined;
849
+ saveEndpointConfig(command, { memory });
850
+ return {
851
+ content: [{ type: "text", text: memory ? `SSH remote server memory updated (${label}). It will be injected while this endpoint is the active remote workspace.` : `SSH remote server memory cleared (${label}).` }],
852
+ details: { endpoint: label, memory },
853
+ };
854
+ }
777
855
  if (params.action === "exec") {
778
856
  const state = await ensureConnected(ctx);
779
857
  if (!params.remoteCommand) throw new Error("remoteCommand is required for exec");
@@ -783,7 +861,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
783
861
  return { content: [{ type: "text", text: resolved }], details: { connected: true, cwd: resolved } };
784
862
  }
785
863
  const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
786
- const output = await withReconnect((client) => execRemote(client, `cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`));
864
+ const timeoutSeconds = parseRemoteTimeout(params.timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
865
+ const output = await withReconnect((client) => execRemote(
866
+ client,
867
+ `cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`,
868
+ false,
869
+ timeoutSeconds,
870
+ ));
787
871
  const formatted = formatRemoteOutput(output.toString());
788
872
  return {
789
873
  content: [{ type: "text", text: formatted.text }],
@@ -802,7 +886,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
802
886
  if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
803
887
  const state = await connectInteractive(command, ctx, params.cwd ?? configuredCwd(command));
804
888
  if (!state) throw new Error(lastConnectionError || "SSH remote connection was cancelled or failed");
805
- return { content: [{ type: "text", text: `Connected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
889
+ return { content: [{ type: "text", text: `Connected: ${endpointDisplayLabel(state)}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
806
890
  },
807
891
  renderResult(result, { expanded }, theme) {
808
892
  return renderRemoteControlResult(result, expanded, theme);
@@ -810,7 +894,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
810
894
  });
811
895
 
812
896
  pi.registerCommand("remote", {
813
- description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | config display-lines N | forward [MAPPINGS] | unforward | exec [--lines N] COMMAND | cd PATH | status | reload | off | forget",
897
+ description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
814
898
  handler: async (args, ctx) => {
815
899
  const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
816
900
  const action = input.toLowerCase();
@@ -818,7 +902,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
818
902
  const config = loadRemoteConfig();
819
903
  const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
820
904
  const active = key === config.activeEndpoint ? "*" : " ";
821
- return `${active} ${key}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
905
+ return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${endpoint.memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
822
906
  });
823
907
  ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
824
908
  return;
@@ -859,6 +943,27 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
859
943
  ctx.ui.notify(`Selected SSH remote endpoint: ${key}; use /remote to connect`, "info");
860
944
  return;
861
945
  }
946
+ if (/^config\s+note(?:\s+|$)/i.test(input)) {
947
+ const value = input.replace(/^config\s+note\s*/i, "").trim();
948
+ if (!value) { ctx.ui.notify("Use /remote config note TEXT or /remote config note --clear", "error"); return; }
949
+ const command = lastCommand || activeSshCommand();
950
+ if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
951
+ const note = value.toLowerCase() === "--clear" ? undefined : value;
952
+ saveEndpointConfig(command, { note });
953
+ if (remote && cacheId(remote) === cacheId(parseSshCommand(command))) status(ctx);
954
+ ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
955
+ return;
956
+ }
957
+ if (/^config\s+memory(?:\s+|$)/i.test(input)) {
958
+ const value = input.replace(/^config\s+memory\s*/i, "").trim();
959
+ if (!value) { ctx.ui.notify("Use /remote config memory TEXT or /remote config memory --clear", "error"); return; }
960
+ const command = lastCommand || activeSshCommand();
961
+ if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
962
+ const memory = value.toLowerCase() === "--clear" ? undefined : value;
963
+ saveEndpointConfig(command, { memory });
964
+ ctx.ui.notify(memory ? `SSH remote server memory updated (${parseSshCommand(command).label}); it will be injected while connected` : `SSH remote server memory cleared (${parseSshCommand(command).label})`, "info");
965
+ return;
966
+ }
862
967
  if (/^config\s+display-lines\s+/i.test(input)) {
863
968
  try {
864
969
  const displayLines = parseDisplayLines(input.replace(/^config\s+display-lines\s+/i, "").trim());
@@ -907,11 +1012,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
907
1012
  if (/^exec\s+/i.test(input)) {
908
1013
  try {
909
1014
  const state = await ensureConnected(ctx);
910
- const execInput = input.replace(/^exec\s+/i, "");
911
- const linesMatch = execInput.match(/^--lines\s+(\S+)\s+([\s\S]+)$/i);
912
- const displayLines = linesMatch ? parseDisplayLines(linesMatch[1]) : configuredDisplayLines();
913
- const remoteCommand = linesMatch ? linesMatch[2]! : execInput;
914
- const output = (await withReconnect((client) => execRemote(client, `cd -- ${quote(state.cwd)} && ${remoteCommand}`))).toString().trim();
1015
+ let execInput = input.replace(/^exec\s+/i, "").trim();
1016
+ let displayLines = configuredDisplayLines();
1017
+ let timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS;
1018
+ while (execInput.startsWith("--")) {
1019
+ const option = execInput.match(/^--(lines|timeout)\s+(\S+)\s+([\s\S]+)$/i);
1020
+ if (!option) throw new Error("Expected --lines N or --timeout SECONDS followed by a command");
1021
+ if (option[1]!.toLowerCase() === "lines") displayLines = parseDisplayLines(option[2]);
1022
+ else timeoutSeconds = parseRemoteTimeout(option[2]);
1023
+ execInput = option[3]!;
1024
+ }
1025
+ const output = (await withReconnect((client) => execRemote(
1026
+ client,
1027
+ `cd -- ${quote(state.cwd)} && ${execInput}`,
1028
+ false,
1029
+ timeoutSeconds,
1030
+ ))).toString().trim();
915
1031
  const formatted = formatRemoteOutput(output);
916
1032
  const preview = previewRemoteOutput(formatted.content, displayLines);
917
1033
  const omitted = formatted.truncation.totalLines > displayLines
@@ -925,7 +1041,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
925
1041
  if (["off", "disconnect", "exit"].includes(action)) { disconnect(ctx); return; }
926
1042
  if (action === "forget") { disconnect(ctx, true); return; }
927
1043
  if (action === "status") {
928
- ctx.ui.notify(remote ? `${remote.label}:${remote.cwd}` : "SSH remote is disconnected", "info");
1044
+ ctx.ui.notify(remote ? `${endpointDisplayLabel(remote)}:${remote.cwd}` : "SSH remote is disconnected", "info");
929
1045
  return;
930
1046
  }
931
1047
  if (["reload", "reconnect"].includes(action)) {
@@ -976,9 +1092,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
976
1092
  }
977
1093
  });
978
1094
  pi.on("before_agent_start", (event) => remote && routeRemoteTools ? {
979
- systemPrompt: event.systemPrompt.replace(
980
- `Current working directory: ${localCwd}`,
981
- `Current working directory: ${remote.cwd} (via SSH ${remote.label}). 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.`,
982
- ),
1095
+ systemPrompt: remoteSystemPrompt(event.systemPrompt, localCwd, remote),
983
1096
  } : undefined);
1097
+ pi.on("context", (event) => {
1098
+ if (!remote || !routeRemoteTools) return undefined;
1099
+ const content = serverMemoryContext(remote);
1100
+ if (!content) return undefined;
1101
+ return {
1102
+ messages: [...event.messages, {
1103
+ role: "custom",
1104
+ customType: "ssh-remote-server-memory",
1105
+ content,
1106
+ display: false,
1107
+ timestamp: Date.now(),
1108
+ }],
1109
+ };
1110
+ });
984
1111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",