pi-ssh-remote 0.1.4 → 0.1.6
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/CHANGELOG.md +140 -0
- package/LICENSE +0 -0
- package/README.md +26 -6
- package/README.zh-CN.md +36 -7
- package/index.ts +412 -52
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable user-visible changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release dates are npm publication dates, and versions follow this repository's pre-1.0 versioning policy documented in `AGENTS.md`.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- None.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- None.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- None.
|
|
20
|
+
|
|
21
|
+
## [0.1.6] - 2026-08-05
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Added configurable line and byte budgets for remote reads, remote commands, and aggregate per-turn tool output.
|
|
26
|
+
- Added bounded range reads for remote text files with normal `offset` and `limit` continuation.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Remote text reads now default to 400 lines or 16 KB, remote command results default to the last 200 lines or 8 KB, and each agent turn defaults to a shared 32 KB remote output budget.
|
|
31
|
+
- Remote command output now streams through bounded buffers, with complete oversized stdout written to a permission-restricted local temporary file.
|
|
32
|
+
- Collapsed command previews are now limited to at most 50 visual lines and remain independent from model-facing output.
|
|
33
|
+
- Moved the complete release history into this changelog and kept concise release indexes in the English and Chinese READMEs.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Fixed remote text reads downloading complete files over SFTP before applying line and byte limits.
|
|
38
|
+
- Fixed `ssh_remote_control exec` accumulating complete stdout in memory before truncation.
|
|
39
|
+
|
|
40
|
+
## [0.1.5] - 2026-08-04
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Added persistent, endpoint-specific server memory through `/remote config memory TEXT` and the `ssh_remote_control` `memory` action.
|
|
45
|
+
- Added automatic injection of that memory into every model request while the matching endpoint is the active remote workspace.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- Server memory is removed from subsequent model requests when remote tool routing is disabled, including disconnect and tunnel-only mode.
|
|
50
|
+
- `/remote config` now shows the memory stored for each endpoint.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- None.
|
|
55
|
+
|
|
56
|
+
## [0.1.4] - 2026-07-31
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- Added persistent endpoint notes through `/remote config note TEXT` and the `ssh_remote_control` `note` action.
|
|
61
|
+
- Added per-command timeout overrides through `/remote exec --timeout SECONDS` and the tool's `timeout` parameter.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- Remote commands now default to a 30-second timeout and endpoint notes appear in status, connection, reconnection, and footer labels.
|
|
66
|
+
- Expanded the documentation around agent-first workflows, multi-server operation, background jobs, and tunnel mode.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Fixed remote commands that could run without a deadline by enforcing GNU `timeout` remotely and a local fallback timer.
|
|
71
|
+
- Fixed timeout reporting for processes terminated with timeout-related exit codes 124 and 137.
|
|
72
|
+
|
|
73
|
+
## [0.1.3] - 2026-07-24
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- Added configurable collapsed command previews with `/remote config display-lines N`, `/remote exec --lines N`, and the tool's `displayLines` parameter.
|
|
78
|
+
- Added expandable custom rendering for remote command results.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- Remote command previews default to the last five visual lines while model-facing output remains limited to 2,000 lines or 50 KB.
|
|
83
|
+
- Oversized complete output is saved to a permission-restricted local temporary file.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- Fixed `/remote exec` truncating its notification to an arbitrary 4,000 characters without preserving or identifying the full output.
|
|
88
|
+
|
|
89
|
+
## [0.1.2] - 2026-07-24
|
|
90
|
+
|
|
91
|
+
### Added
|
|
92
|
+
|
|
93
|
+
- None.
|
|
94
|
+
|
|
95
|
+
### Changed
|
|
96
|
+
|
|
97
|
+
- Republished the 0.1.1 runtime as 0.1.2 to align npm package version metadata; there were no runtime code changes.
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
|
|
101
|
+
- None.
|
|
102
|
+
|
|
103
|
+
## [0.1.1] - 2026-07-24
|
|
104
|
+
|
|
105
|
+
### Added
|
|
106
|
+
|
|
107
|
+
- Added the shorter `/remote ssh USER@HOST [-p PORT]` connection command.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- `/remote ssh` now saves, selects, and immediately connects to the endpoint instead of only storing it.
|
|
112
|
+
|
|
113
|
+
### Fixed
|
|
114
|
+
|
|
115
|
+
- Fixed help and error text that still instructed users to run the obsolete `/remote config ssh ssh ...` form.
|
|
116
|
+
|
|
117
|
+
## [0.1.0] - 2026-07-24
|
|
118
|
+
|
|
119
|
+
### Added
|
|
120
|
+
|
|
121
|
+
- Initial release with persistent multi-endpoint SSH workspaces and remote working directories.
|
|
122
|
+
- Added transparent routing for Pi's `read`, `write`, `edit`, `bash`, and user shell operations.
|
|
123
|
+
- Added SSH-agent/password authentication, interactive host-key verification, automatic reconnection, SFTP file access, bounded command output, and local TCP forwarding.
|
|
124
|
+
|
|
125
|
+
### Changed
|
|
126
|
+
|
|
127
|
+
- None.
|
|
128
|
+
|
|
129
|
+
### Fixed
|
|
130
|
+
|
|
131
|
+
- None.
|
|
132
|
+
|
|
133
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...HEAD
|
|
134
|
+
[0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
|
|
135
|
+
[0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
|
|
136
|
+
[0.1.4]: https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.4
|
|
137
|
+
[0.1.3]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.3
|
|
138
|
+
[0.1.2]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.2
|
|
139
|
+
[0.1.1]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.1
|
|
140
|
+
[0.1.0]: https://www.npmjs.com/package/pi-ssh-remote/v/0.1.0
|
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -31,11 +31,11 @@ The extension exposes `ssh_remote_control` for the agent and `/remote` for the u
|
|
|
31
31
|
|
|
32
32
|
### Stateful endpoints instead of disposable SSH commands
|
|
33
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.
|
|
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
35
|
|
|
36
36
|
### Resilient and bounded by default
|
|
37
37
|
|
|
38
|
-
Dropped connections are automatically re-established during the active session. Remote commands have a 30-second default timeout,
|
|
38
|
+
Dropped connections are automatically re-established during the active session. Remote commands have a 30-second default timeout. Remote text reads fetch focused ranges instead of downloading complete files, command output is streamed through bounded buffers, and a 32 KB per-turn budget prevents parallel tools from flooding model context. Complete oversized command output is preserved in a permission-restricted temporary file.
|
|
39
39
|
|
|
40
40
|
### Hybrid local/remote workflows
|
|
41
41
|
|
|
@@ -66,6 +66,7 @@ Requires Node.js 20+. The remote server must provide Bash, SFTP, and GNU `timeou
|
|
|
66
66
|
/remote ssh root@gpu-box.example.com -p 2202
|
|
67
67
|
/remote cd /srv/project
|
|
68
68
|
/remote config note H100 training server
|
|
69
|
+
/remote config memory Use /opt/conda/bin/python and never stop shared jobs.
|
|
69
70
|
/remote status
|
|
70
71
|
```
|
|
71
72
|
|
|
@@ -120,7 +121,7 @@ The default foreground timeout prevents an accidental long-running command from
|
|
|
120
121
|
/remote
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
Endpoint notes
|
|
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.
|
|
124
125
|
|
|
125
126
|
### 4. Expose a remote service while editing locally
|
|
126
127
|
|
|
@@ -165,6 +166,8 @@ These requests are handled through the agent-facing `ssh_remote_control` tool; s
|
|
|
165
166
|
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
166
167
|
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
167
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 |
|
|
168
171
|
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
169
172
|
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
170
173
|
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
@@ -173,7 +176,12 @@ These requests are handled through the agent-facing `ssh_remote_control` tool; s
|
|
|
173
176
|
| `/remote exec COMMAND` | Run one command in the remote cwd |
|
|
174
177
|
| `/remote exec --timeout 60 COMMAND` | Override the command timeout |
|
|
175
178
|
| `/remote exec --lines 20 COMMAND` | Override collapsed preview lines |
|
|
176
|
-
| `/remote config display-lines 10` | Set default collapsed preview lines |
|
|
179
|
+
| `/remote config display-lines 10` | Set default collapsed preview lines (maximum 50) |
|
|
180
|
+
| `/remote config read-max-lines 400` | Set the remote read line budget |
|
|
181
|
+
| `/remote config read-max-bytes 16384` | Set the remote read byte budget |
|
|
182
|
+
| `/remote config exec-max-lines 200` | Set the remote command line budget |
|
|
183
|
+
| `/remote config exec-max-bytes 8192` | Set the remote command byte budget |
|
|
184
|
+
| `/remote config turn-max-bytes 32768` | Set the aggregate per-turn remote output budget |
|
|
177
185
|
| `/remote status` | Show the active workspace |
|
|
178
186
|
| `/remote reload` | Reconnect the active workspace |
|
|
179
187
|
| `/remote off` | Disconnect and return tools to local execution |
|
|
@@ -187,12 +195,24 @@ Endpoint configuration is stored locally in:
|
|
|
187
195
|
~/.pi/agent/ssh-remote-config.json
|
|
188
196
|
```
|
|
189
197
|
|
|
190
|
-
Saved values include endpoints, active endpoint, notes, remote working directories, forwards, and
|
|
198
|
+
Saved values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, preview settings, and model-output budgets. 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.
|
|
191
199
|
|
|
192
|
-
New or changed host keys require interactive confirmation and are stored separately from OpenSSH.
|
|
200
|
+
New or changed host keys require interactive confirmation and are stored separately from OpenSSH. By default, remote text reads return at most 400 lines or 16 KB, remote command results return the last 200 lines or 8 KB, and all remote tools in one agent turn share a 32 KB output budget. Text reads support `offset`/`limit` continuation without downloading the complete remote file. Oversized command output is streamed to a permission-restricted temporary local file rather than accumulated in memory. Configured limits may be raised only to the extension's hard safety ceilings. Preview-line settings affect only the collapsed UI and never increase model output.
|
|
193
201
|
|
|
194
202
|
## Current SSH scope
|
|
195
203
|
|
|
196
204
|
The extension currently supports direct SSH commands with `-p` and `-l`. It does not yet consume `~/.ssh/config`, `IdentityFile`, or ProxyJump settings.
|
|
197
205
|
|
|
206
|
+
## Releases
|
|
207
|
+
|
|
208
|
+
Latest release: [v0.1.6](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.6)
|
|
209
|
+
|
|
210
|
+
| Version | Date | Highlights |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | Bounded remote reads, streamed command output, and per-turn output budgets |
|
|
213
|
+
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | Endpoint-specific server memory and automatic context injection |
|
|
214
|
+
| [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | Endpoint notes and enforced remote command timeouts |
|
|
215
|
+
|
|
216
|
+
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
217
|
+
|
|
198
218
|
MIT licensed.
|
package/README.zh-CN.md
CHANGED
|
@@ -51,9 +51,10 @@ SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
51
51
|
- SSH 地址;
|
|
52
52
|
- 默认工作目录;
|
|
53
53
|
- 服务器备注;
|
|
54
|
+
- 服务器特定记忆;
|
|
54
55
|
- 端口转发配置。
|
|
55
56
|
|
|
56
|
-
例如可以把几台机器分别备注为 `8xH100
|
|
57
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。还可以为每个 `user@host:port` 保存独立记忆,例如指定 Python 环境、共享任务保护规则或部署约定。连接该 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
|
|
57
58
|
|
|
58
59
|
### 断线后可以自动恢复
|
|
59
60
|
|
|
@@ -61,7 +62,7 @@ SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
61
62
|
|
|
62
63
|
### 对 Agent 上下文更友好
|
|
63
64
|
|
|
64
|
-
远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent
|
|
65
|
+
远程命令默认 30 秒超时,避免某个前台任务长期占住 Agent。远程文本读取会按范围获取,不再先下载完整文件;命令输出通过有界缓冲流式处理;同一轮远端工具共享 32 KB 输出预算,避免并行调用迅速占满上下文。超限命令的完整输出会保存到权限受限的临时文件,方便后续按需检查。
|
|
65
66
|
|
|
66
67
|
### 可以远端跑服务、本地改代码
|
|
67
68
|
|
|
@@ -106,6 +107,7 @@ pi install npm:pi-ssh-remote
|
|
|
106
107
|
```text
|
|
107
108
|
/remote cd /srv/project
|
|
108
109
|
/remote config note H100 训练机
|
|
110
|
+
/remote config memory 使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
|
|
109
111
|
```
|
|
110
112
|
|
|
111
113
|
查看当前状态:
|
|
@@ -218,6 +220,8 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
218
220
|
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
219
221
|
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
220
222
|
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
223
|
+
| `/remote config memory TEXT` | 保存连接该服务器时自动注入上下文的记忆 |
|
|
224
|
+
| `/remote config memory --clear` | 清除当前服务器的特定记忆 |
|
|
221
225
|
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
222
226
|
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
223
227
|
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
@@ -226,7 +230,12 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
226
230
|
| `/remote exec COMMAND` | 在当前远程目录执行一次命令 |
|
|
227
231
|
| `/remote exec --timeout 60 COMMAND` | 单独设置本次命令的超时时间 |
|
|
228
232
|
| `/remote exec --lines 20 COMMAND` | 单独设置本次折叠显示的行数 |
|
|
229
|
-
| `/remote config display-lines 10` |
|
|
233
|
+
| `/remote config display-lines 10` | 设置默认折叠显示行数(最大 50) |
|
|
234
|
+
| `/remote config read-max-lines 400` | 设置远端读取行数预算 |
|
|
235
|
+
| `/remote config read-max-bytes 16384` | 设置远端读取字节预算 |
|
|
236
|
+
| `/remote config exec-max-lines 200` | 设置远端命令行数预算 |
|
|
237
|
+
| `/remote config exec-max-bytes 8192` | 设置远端命令字节预算 |
|
|
238
|
+
| `/remote config turn-max-bytes 32768` | 设置每轮远端工具总输出预算 |
|
|
230
239
|
| `/remote status` | 查看当前连接和工作目录 |
|
|
231
240
|
| `/remote reload` | 重新连接当前服务器 |
|
|
232
241
|
| `/remote off` | 断开连接并返回本地 |
|
|
@@ -245,9 +254,13 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
245
254
|
- 已保存的服务器;
|
|
246
255
|
- 当前选中的服务器;
|
|
247
256
|
- 服务器备注;
|
|
257
|
+
- 服务器特定记忆;
|
|
248
258
|
- 默认远程目录;
|
|
249
259
|
- 端口转发配置;
|
|
250
|
-
-
|
|
260
|
+
- 命令预览设置;
|
|
261
|
+
- 模型输出预算。
|
|
262
|
+
|
|
263
|
+
服务器记忆是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当对应 endpoint 作为远端工作区启用时才会加入每次模型请求。
|
|
251
264
|
|
|
252
265
|
密码不会写入配置文件。插件会优先使用 SSH agent;如果需要手动输入密码,密码只会缓存在当前 Pi 进程的内存中。
|
|
253
266
|
|
|
@@ -256,12 +269,28 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
|
|
|
256
269
|
- 第一次连接新服务器时,需要确认主机密钥;
|
|
257
270
|
- 主机密钥发生变化时,会再次要求确认;
|
|
258
271
|
- 远程命令默认 30 秒超时;
|
|
259
|
-
-
|
|
260
|
-
-
|
|
261
|
-
-
|
|
272
|
+
- 远程文本读取默认最多返回 400 行或 16 KB,并支持通过 `offset`/`limit` 继续读取;
|
|
273
|
+
- 远程命令默认最多返回最后 200 行或 8 KB;
|
|
274
|
+
- 同一轮所有远端工具默认共享 32 KB 模型输出预算;
|
|
275
|
+
- 文本范围读取不会先通过 SFTP 下载完整远程文件;
|
|
276
|
+
- 超限命令会流式写入权限受限的本地临时文件,不在内存中累积完整输出;
|
|
277
|
+
- 各项限制可以配置,但不能超过扩展的硬安全上限;
|
|
278
|
+
- 折叠显示行数最大为 50,只影响界面,不会增加模型输出。
|
|
262
279
|
|
|
263
280
|
## 当前限制
|
|
264
281
|
|
|
265
282
|
目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config`,也不支持 `IdentityFile` 和 ProxyJump。
|
|
266
283
|
|
|
284
|
+
## 版本发布
|
|
285
|
+
|
|
286
|
+
最新版本:[v0.1.6](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.6)
|
|
287
|
+
|
|
288
|
+
| 版本 | 日期 | 主要内容 |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | 有界远端读取、流式命令输出与每轮输出预算 |
|
|
291
|
+
| [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | endpoint 级服务器记忆与自动上下文注入 |
|
|
292
|
+
| [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | endpoint 备注与远程命令强制超时 |
|
|
293
|
+
|
|
294
|
+
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
295
|
+
|
|
267
296
|
MIT License。
|
package/index.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
10
|
-
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
10
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { dirname, join, posix, relative, sep } from "node:path";
|
|
12
12
|
import { createServer, type Server, type Socket } from "node:net";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { Type } from "typebox";
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
createWriteTool,
|
|
25
25
|
formatSize,
|
|
26
26
|
keyHint,
|
|
27
|
+
truncateHead,
|
|
27
28
|
truncateTail,
|
|
28
29
|
type BashOperations,
|
|
29
30
|
type EditOperations,
|
|
@@ -55,12 +56,18 @@ interface RemoteEndpointConfig {
|
|
|
55
56
|
remoteCwd?: string;
|
|
56
57
|
forwards?: string[];
|
|
57
58
|
note?: string;
|
|
59
|
+
memory?: string;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
interface RemoteConfig {
|
|
61
63
|
activeEndpoint?: string;
|
|
62
64
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
63
65
|
displayLines?: number;
|
|
66
|
+
readMaxLines?: number;
|
|
67
|
+
readMaxBytes?: number;
|
|
68
|
+
execMaxLines?: number;
|
|
69
|
+
execMaxBytes?: number;
|
|
70
|
+
turnMaxBytes?: number;
|
|
64
71
|
/** Legacy fields migrated into endpoints on the next config write. */
|
|
65
72
|
sshCommand?: string;
|
|
66
73
|
remoteCwd?: string;
|
|
@@ -78,6 +85,14 @@ const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
|
78
85
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
79
86
|
const FALLBACK_REMOTE_CWD = "~";
|
|
80
87
|
const DEFAULT_DISPLAY_LINES = 5;
|
|
88
|
+
const MAX_DISPLAY_LINES = 50;
|
|
89
|
+
const DEFAULT_READ_MAX_LINES = 400;
|
|
90
|
+
const DEFAULT_READ_MAX_BYTES = 16 * 1024;
|
|
91
|
+
const DEFAULT_EXEC_MAX_LINES = 200;
|
|
92
|
+
const DEFAULT_EXEC_MAX_BYTES = 8 * 1024;
|
|
93
|
+
const DEFAULT_TURN_MAX_BYTES = 32 * 1024;
|
|
94
|
+
const MIN_MODEL_OUTPUT_BYTES = 1024;
|
|
95
|
+
const OUTPUT_FOOTER_RESERVE_BYTES = 512;
|
|
81
96
|
const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
|
|
82
97
|
const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
|
|
83
98
|
const CACHE_KEY = "__piHpcCredentialCacheV1";
|
|
@@ -157,12 +172,28 @@ function quote(value: string): string {
|
|
|
157
172
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
158
173
|
}
|
|
159
174
|
|
|
175
|
+
function parseBoundedInteger(value: unknown, label: string, maximum: number): number {
|
|
176
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
177
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) {
|
|
178
|
+
throw new Error(`${label} must be an integer from 1 to ${maximum}`);
|
|
179
|
+
}
|
|
180
|
+
return parsed;
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
function parseDisplayLines(value: unknown): number {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
184
|
+
return parseBoundedInteger(value, "Display lines", MAX_DISPLAY_LINES);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseOutputLines(value: unknown, label: string): number {
|
|
188
|
+
return parseBoundedInteger(value, label, DEFAULT_MAX_LINES);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseOutputBytes(value: unknown, label: string, maximum = DEFAULT_MAX_BYTES): number {
|
|
192
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
193
|
+
if (!Number.isInteger(parsed) || parsed < MIN_MODEL_OUTPUT_BYTES || parsed > maximum) {
|
|
194
|
+
throw new Error(`${label} must be an integer from ${MIN_MODEL_OUTPUT_BYTES} to ${maximum}`);
|
|
164
195
|
}
|
|
165
|
-
return
|
|
196
|
+
return parsed;
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
function parseRemoteTimeout(value: unknown): number {
|
|
@@ -182,6 +213,15 @@ function configuredDisplayLines(config = loadRemoteConfig()): number {
|
|
|
182
213
|
catch { return DEFAULT_DISPLAY_LINES; }
|
|
183
214
|
}
|
|
184
215
|
|
|
216
|
+
function configuredOutputLimits(config = loadRemoteConfig()) {
|
|
217
|
+
const readMaxLines = (() => { try { return parseOutputLines(config.readMaxLines ?? DEFAULT_READ_MAX_LINES, "Read max lines"); } catch { return DEFAULT_READ_MAX_LINES; } })();
|
|
218
|
+
const readMaxBytes = (() => { try { return parseOutputBytes(config.readMaxBytes ?? DEFAULT_READ_MAX_BYTES, "Read max bytes"); } catch { return DEFAULT_READ_MAX_BYTES; } })();
|
|
219
|
+
const execMaxLines = (() => { try { return parseOutputLines(config.execMaxLines ?? DEFAULT_EXEC_MAX_LINES, "Exec max lines"); } catch { return DEFAULT_EXEC_MAX_LINES; } })();
|
|
220
|
+
const execMaxBytes = (() => { try { return parseOutputBytes(config.execMaxBytes ?? DEFAULT_EXEC_MAX_BYTES, "Exec max bytes"); } catch { return DEFAULT_EXEC_MAX_BYTES; } })();
|
|
221
|
+
const turnMaxBytes = (() => { try { return parseOutputBytes(config.turnMaxBytes ?? DEFAULT_TURN_MAX_BYTES, "Turn max bytes", DEFAULT_MAX_BYTES * 4); } catch { return DEFAULT_TURN_MAX_BYTES; } })();
|
|
222
|
+
return { readMaxLines, readMaxBytes, execMaxLines, execMaxBytes, turnMaxBytes };
|
|
223
|
+
}
|
|
224
|
+
|
|
185
225
|
function commandFromEndpointKey(key: string): string | undefined {
|
|
186
226
|
const match = key.match(/^([^@]+)@(.+):(\d+)$/);
|
|
187
227
|
if (!match) return undefined;
|
|
@@ -214,13 +254,27 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
|
|
|
214
254
|
if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
|
|
215
255
|
activeEndpoint ??= Object.keys(endpoints)[0];
|
|
216
256
|
let displayLines: number | undefined;
|
|
217
|
-
|
|
218
|
-
|
|
257
|
+
let readMaxLines: number | undefined;
|
|
258
|
+
let readMaxBytes: number | undefined;
|
|
259
|
+
let execMaxLines: number | undefined;
|
|
260
|
+
let execMaxBytes: number | undefined;
|
|
261
|
+
let turnMaxBytes: number | undefined;
|
|
262
|
+
try { displayLines = config.displayLines === undefined ? undefined : parseDisplayLines(config.displayLines); } catch {}
|
|
263
|
+
try { readMaxLines = config.readMaxLines === undefined ? undefined : parseOutputLines(config.readMaxLines, "Read max lines"); } catch {}
|
|
264
|
+
try { readMaxBytes = config.readMaxBytes === undefined ? undefined : parseOutputBytes(config.readMaxBytes, "Read max bytes"); } catch {}
|
|
265
|
+
try { execMaxLines = config.execMaxLines === undefined ? undefined : parseOutputLines(config.execMaxLines, "Exec max lines"); } catch {}
|
|
266
|
+
try { execMaxBytes = config.execMaxBytes === undefined ? undefined : parseOutputBytes(config.execMaxBytes, "Exec max bytes"); } catch {}
|
|
267
|
+
try { turnMaxBytes = config.turnMaxBytes === undefined ? undefined : parseOutputBytes(config.turnMaxBytes, "Turn max bytes", DEFAULT_MAX_BYTES * 4); } catch {}
|
|
219
268
|
|
|
220
269
|
return {
|
|
221
270
|
...(activeEndpoint ? { activeEndpoint } : {}),
|
|
222
271
|
...(Object.keys(endpoints).length ? { endpoints } : {}),
|
|
223
272
|
...(displayLines !== undefined ? { displayLines } : {}),
|
|
273
|
+
...(readMaxLines !== undefined ? { readMaxLines } : {}),
|
|
274
|
+
...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
|
|
275
|
+
...(execMaxLines !== undefined ? { execMaxLines } : {}),
|
|
276
|
+
...(execMaxBytes !== undefined ? { execMaxBytes } : {}),
|
|
277
|
+
...(turnMaxBytes !== undefined ? { turnMaxBytes } : {}),
|
|
224
278
|
};
|
|
225
279
|
}
|
|
226
280
|
|
|
@@ -252,6 +306,23 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
|
|
|
252
306
|
return note ? `${note} (${endpoint.label})` : endpoint.label;
|
|
253
307
|
}
|
|
254
308
|
|
|
309
|
+
function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
|
|
310
|
+
return endpointConfig(config, endpoint.command).memory?.trim() || undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
|
|
314
|
+
return systemPrompt.replace(
|
|
315
|
+
`Current working directory: ${localCwd}`,
|
|
316
|
+
`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.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function serverMemoryContext(remote: RemoteState): string | undefined {
|
|
321
|
+
const memory = endpointMemory(remote);
|
|
322
|
+
if (!memory) return undefined;
|
|
323
|
+
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>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
255
326
|
function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
|
|
256
327
|
const config = loadRemoteConfig();
|
|
257
328
|
const key = cacheId(parseSshCommand(command));
|
|
@@ -281,18 +352,99 @@ function displayFingerprint(hex: string): string {
|
|
|
281
352
|
return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
|
|
282
353
|
}
|
|
283
354
|
|
|
284
|
-
function
|
|
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 };
|
|
288
|
-
|
|
355
|
+
function createOutputFile(): { path: string; fd: number } {
|
|
289
356
|
const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
357
|
+
const path = join(outputDir, "output.log");
|
|
358
|
+
return { path, fd: openSync(path, "w", 0o600) };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function saveOutput(output: string): string {
|
|
362
|
+
const file = createOutputFile();
|
|
363
|
+
try { writeSync(file.fd, output); }
|
|
364
|
+
finally { closeSync(file.fd); }
|
|
365
|
+
return file.path;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
class RemoteExecAccumulator {
|
|
369
|
+
private chunks: Buffer[] = [];
|
|
370
|
+
private tail = Buffer.alloc(0);
|
|
371
|
+
private stderrTail = Buffer.alloc(0);
|
|
372
|
+
private outputFile: { path: string; fd: number } | undefined;
|
|
373
|
+
private totalBytes = 0;
|
|
374
|
+
private newlineCount = 0;
|
|
375
|
+
private lastByte: number | undefined;
|
|
376
|
+
|
|
377
|
+
constructor(private maxLines: number, private maxBytes: number) {}
|
|
378
|
+
|
|
379
|
+
append(chunk: Buffer): void {
|
|
380
|
+
if (!chunk.length) return;
|
|
381
|
+
this.totalBytes += chunk.length;
|
|
382
|
+
for (const byte of chunk) if (byte === 10) this.newlineCount++;
|
|
383
|
+
this.lastByte = chunk[chunk.length - 1];
|
|
384
|
+
|
|
385
|
+
if (!this.outputFile && this.totalBytes > this.maxBytes) {
|
|
386
|
+
this.outputFile = createOutputFile();
|
|
387
|
+
for (const previous of this.chunks) writeSync(this.outputFile.fd, previous);
|
|
388
|
+
this.chunks = [];
|
|
389
|
+
}
|
|
390
|
+
if (this.outputFile) writeSync(this.outputFile.fd, chunk);
|
|
391
|
+
else this.chunks.push(chunk);
|
|
392
|
+
|
|
393
|
+
this.tail = Buffer.concat([this.tail, chunk]);
|
|
394
|
+
const tailBytes = this.maxBytes + OUTPUT_FOOTER_RESERVE_BYTES + 4096;
|
|
395
|
+
if (this.tail.length > tailBytes) this.tail = this.tail.subarray(this.tail.length - tailBytes);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
appendStderr(chunk: Buffer): void {
|
|
399
|
+
this.stderrTail = Buffer.concat([this.stderrTail, chunk]);
|
|
400
|
+
if (this.stderrTail.length > this.maxBytes) this.stderrTail = this.stderrTail.subarray(this.stderrTail.length - this.maxBytes);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private ensureOutputFile(): string {
|
|
404
|
+
if (!this.outputFile) {
|
|
405
|
+
this.outputFile = createOutputFile();
|
|
406
|
+
for (const chunk of this.chunks) writeSync(this.outputFile.fd, chunk);
|
|
407
|
+
this.chunks = [];
|
|
408
|
+
}
|
|
409
|
+
return this.outputFile.path;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
finish() {
|
|
413
|
+
const totalLines = this.totalBytes ? this.newlineCount + (this.lastByte === 10 ? 0 : 1) : 0;
|
|
414
|
+
const source = this.outputFile ? this.tail.toString("utf8") : Buffer.concat(this.chunks).toString("utf8");
|
|
415
|
+
const contentBudget = Math.max(1, this.maxBytes - OUTPUT_FOOTER_RESERVE_BYTES);
|
|
416
|
+
const base = truncateTail(source, { maxLines: this.maxLines, maxBytes: contentBudget });
|
|
417
|
+
const truncated = this.totalBytes > contentBudget || totalLines > this.maxLines || base.truncated;
|
|
418
|
+
const truncation = {
|
|
419
|
+
...base,
|
|
420
|
+
truncated,
|
|
421
|
+
totalLines,
|
|
422
|
+
totalBytes: this.totalBytes,
|
|
423
|
+
maxLines: this.maxLines,
|
|
424
|
+
maxBytes: this.maxBytes,
|
|
425
|
+
};
|
|
426
|
+
const content = base.content || "Remote command completed.";
|
|
427
|
+
let fullOutputPath: string | undefined;
|
|
428
|
+
let text = content;
|
|
429
|
+
if (truncated) {
|
|
430
|
+
fullOutputPath = this.ensureOutputFile();
|
|
431
|
+
const startLine = Math.max(1, totalLines - base.outputLines + 1);
|
|
432
|
+
text += `\n\n[Showing lines ${startLine}-${totalLines} of ${totalLines} (${formatSize(this.maxBytes)} model-output limit). Full output: ${fullOutputPath}]`;
|
|
433
|
+
}
|
|
434
|
+
this.close();
|
|
435
|
+
return { text, content, truncation, fullOutputPath };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
errorMessage(): string {
|
|
439
|
+
return this.stderrTail.toString("utf8").trim();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
close(): void {
|
|
443
|
+
if (!this.outputFile) return;
|
|
444
|
+
const file = this.outputFile;
|
|
445
|
+
this.outputFile = undefined;
|
|
446
|
+
closeSync(file.fd);
|
|
447
|
+
}
|
|
296
448
|
}
|
|
297
449
|
|
|
298
450
|
function previewRemoteOutput(output: string, displayLines: number): string {
|
|
@@ -411,6 +563,53 @@ function execRemote(
|
|
|
411
563
|
});
|
|
412
564
|
}
|
|
413
565
|
|
|
566
|
+
function execRemoteLimited(
|
|
567
|
+
client: Client,
|
|
568
|
+
command: string,
|
|
569
|
+
timeoutSeconds: number,
|
|
570
|
+
maxLines: number,
|
|
571
|
+
maxBytes: number,
|
|
572
|
+
): Promise<ReturnType<RemoteExecAccumulator["finish"]>> {
|
|
573
|
+
const resolvedTimeout = parseRemoteTimeout(timeoutSeconds);
|
|
574
|
+
return new Promise((resolve, reject) => {
|
|
575
|
+
client.exec(withRemoteTimeout(command, resolvedTimeout), (error, stream) => {
|
|
576
|
+
if (error) return reject(error);
|
|
577
|
+
const accumulator = new RemoteExecAccumulator(maxLines, maxBytes);
|
|
578
|
+
let locallyTimedOut = false;
|
|
579
|
+
let settled = false;
|
|
580
|
+
const timer = setTimeout(() => {
|
|
581
|
+
locallyTimedOut = true;
|
|
582
|
+
stream.close();
|
|
583
|
+
}, (resolvedTimeout + 8) * 1000);
|
|
584
|
+
const cleanup = () => clearTimeout(timer);
|
|
585
|
+
const fail = (failure: Error) => {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
cleanup();
|
|
589
|
+
accumulator.close();
|
|
590
|
+
reject(failure);
|
|
591
|
+
};
|
|
592
|
+
stream.on("data", (chunk: Buffer) => accumulator.append(chunk));
|
|
593
|
+
stream.stderr.on("data", (chunk: Buffer) => accumulator.appendStderr(chunk));
|
|
594
|
+
stream.once("error", (streamError: Error) => fail(streamError));
|
|
595
|
+
stream.on("close", (code: number | null) => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
cleanup();
|
|
598
|
+
if (locallyTimedOut || code === 124 || code === 137) {
|
|
599
|
+
fail(new Error(`Remote command timed out after ${resolvedTimeout} seconds`));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (code !== 0) {
|
|
603
|
+
fail(new Error(accumulator.errorMessage() || `Remote command exited with code ${code}`));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
settled = true;
|
|
607
|
+
resolve(accumulator.finish());
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
414
613
|
function getSftp(client: Client): Promise<SFTPWrapper> {
|
|
415
614
|
return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
|
|
416
615
|
}
|
|
@@ -462,6 +661,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
462
661
|
const forwardServers = new Map<number, Server>();
|
|
463
662
|
let lastConnectionError: string | undefined;
|
|
464
663
|
let lastCommand = credentialCache.resume?.command ?? activeSshCommand() ?? "";
|
|
664
|
+
let turnOutputBytes = 0;
|
|
465
665
|
|
|
466
666
|
const configuredCwd = (command: string): string =>
|
|
467
667
|
endpointConfig(loadRemoteConfig(), command).remoteCwd || FALLBACK_REMOTE_CWD;
|
|
@@ -477,6 +677,62 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
477
677
|
return path;
|
|
478
678
|
};
|
|
479
679
|
|
|
680
|
+
const remotePath = (path: string): string => {
|
|
681
|
+
const normalized = path.replace(/^@/, "");
|
|
682
|
+
if (normalized.startsWith("/")) return mapPath(normalized);
|
|
683
|
+
if (!remote) return normalized;
|
|
684
|
+
return posix.join(remote.cwd, normalized);
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
const limitRemoteToolResult = (result: any, kind: "read" | "exec", startLine = 1, requestedMaxLines?: number) => {
|
|
688
|
+
const limits = configuredOutputLimits();
|
|
689
|
+
const configuredMaxBytes = kind === "read" ? limits.readMaxBytes : limits.execMaxBytes;
|
|
690
|
+
const configuredMaxLines = kind === "read" ? limits.readMaxLines : limits.execMaxLines;
|
|
691
|
+
const maxLines = Math.min(configuredMaxLines, requestedMaxLines ?? configuredMaxLines);
|
|
692
|
+
const remaining = Math.max(0, limits.turnMaxBytes - turnOutputBytes);
|
|
693
|
+
if (remaining < MIN_MODEL_OUTPUT_BYTES) {
|
|
694
|
+
const text = `[Remote tool output omitted because this turn has used its ${formatSize(limits.turnMaxBytes)} model-output budget. Run a narrower follow-up command.]`;
|
|
695
|
+
turnOutputBytes += Buffer.byteLength(text, "utf8");
|
|
696
|
+
return { ...result, content: [{ type: "text", text }], details: { ...(result.details ?? {}), turnBudgetExceeded: true } };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const maxBytes = Math.min(configuredMaxBytes, remaining);
|
|
700
|
+
const textIndex = result.content?.findIndex((item: any) => item.type === "text") ?? -1;
|
|
701
|
+
if (textIndex < 0) return result;
|
|
702
|
+
const original = result.content[textIndex].text ?? "";
|
|
703
|
+
const reserveBytes = result.details?.modelLimited ? 0 : OUTPUT_FOOTER_RESERVE_BYTES;
|
|
704
|
+
const contentBudget = Math.max(1, maxBytes - reserveBytes);
|
|
705
|
+
const truncation = kind === "read"
|
|
706
|
+
? truncateHead(original, { maxLines, maxBytes: contentBudget })
|
|
707
|
+
: truncateTail(original, { maxLines, maxBytes: contentBudget });
|
|
708
|
+
let text = truncation.content;
|
|
709
|
+
let fullOutputPath = result.details?.fullOutputPath as string | undefined;
|
|
710
|
+
if (truncation.firstLineExceedsLimit) {
|
|
711
|
+
text = `[Line ${startLine} exceeds the ${formatSize(maxBytes)} remote read limit. Use bash with sed/head -c to inspect a bounded fragment.]`;
|
|
712
|
+
} else if (truncation.truncated) {
|
|
713
|
+
if (kind === "read") {
|
|
714
|
+
const nextOffset = startLine + truncation.outputLines;
|
|
715
|
+
text += `\n\n[Showing ${truncation.outputLines} lines (${formatSize(maxBytes)} remote read limit). Use offset=${nextOffset} to continue.]`;
|
|
716
|
+
} else {
|
|
717
|
+
fullOutputPath ??= saveOutput(original);
|
|
718
|
+
text += `\n\n[Showing the last ${truncation.outputLines} lines (${formatSize(maxBytes)} remote exec limit). Full output: ${fullOutputPath}]`;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const content = [...result.content];
|
|
722
|
+
content[textIndex] = { ...content[textIndex], text: text || (kind === "exec" ? "Remote command completed." : "") };
|
|
723
|
+
const actualBytes = Buffer.byteLength(content[textIndex].text, "utf8");
|
|
724
|
+
turnOutputBytes += actualBytes;
|
|
725
|
+
return {
|
|
726
|
+
...result,
|
|
727
|
+
content,
|
|
728
|
+
details: {
|
|
729
|
+
...(result.details ?? {}),
|
|
730
|
+
...(truncation.truncated ? { truncation } : {}),
|
|
731
|
+
...(fullOutputPath ? { fullOutputPath } : {}),
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
|
|
480
736
|
const status = (ctx: any) => {
|
|
481
737
|
currentCtx = ctx;
|
|
482
738
|
if (!remote) ctx.ui.setStatus("ssh-remote", undefined);
|
|
@@ -695,19 +951,55 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
695
951
|
ctx.ui.notify(forgetPassword ? "SSH remote disconnected and cached password cleared" : "SSH remote mode disabled (password remains cached in memory only)", "info");
|
|
696
952
|
};
|
|
697
953
|
|
|
954
|
+
const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
|
|
955
|
+
try {
|
|
956
|
+
return (await withReconnect((client) => execRemote(client, `file --mime-type -b -- ${quote(remotePath(path))}`))).toString().trim() || undefined;
|
|
957
|
+
} catch { return undefined; }
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const isTextMimeType = (mime: string): boolean =>
|
|
961
|
+
mime.startsWith("text/") || /\/(?:json|ld\+json|xml|javascript|x-sh|x-shellscript|x-empty)$/.test(mime);
|
|
962
|
+
|
|
698
963
|
const remoteReadOps = (): ReadOperations => ({
|
|
699
964
|
readFile: (path) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
700
965
|
new Promise<Buffer>((resolve, reject) => sftp.readFile(mapPath(path), (error, data) => error ? reject(error) : resolve(data))))),
|
|
701
966
|
access: (path) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
702
967
|
new Promise<void>((resolve, reject) => sftp.stat(mapPath(path), (error) => error ? reject(error) : resolve())))),
|
|
703
968
|
detectImageMimeType: async (path) => {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime) ? mime : null;
|
|
707
|
-
} catch { return null; }
|
|
969
|
+
const mime = await detectRemoteMimeType(path);
|
|
970
|
+
return mime && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime) ? mime : null;
|
|
708
971
|
},
|
|
709
972
|
});
|
|
710
973
|
|
|
974
|
+
const executeRemoteRead = async (id: string, params: any, signal: AbortSignal | undefined, update: any) => {
|
|
975
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
976
|
+
const path = remotePath(params.path);
|
|
977
|
+
await withReconnect((client) => withSftp(client, (sftp) =>
|
|
978
|
+
new Promise<void>((resolve, reject) => sftp.stat(path, (error) => error ? reject(error) : resolve()))));
|
|
979
|
+
const mime = await detectRemoteMimeType(path);
|
|
980
|
+
if (mime && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime)) {
|
|
981
|
+
const result = await createReadTool(localCwd, { operations: remoteReadOps() }).execute(id, params, signal, update);
|
|
982
|
+
return limitRemoteToolResult(result, "read", params.offset ?? 1);
|
|
983
|
+
}
|
|
984
|
+
if (mime && !isTextMimeType(mime)) throw new Error(`Refusing to read remote binary file as text (${mime}): ${params.path}`);
|
|
985
|
+
|
|
986
|
+
const limits = configuredOutputLimits();
|
|
987
|
+
const startLine = Math.max(1, Math.floor(Number(params.offset ?? 1)));
|
|
988
|
+
const requestedLines = Math.max(1, Math.floor(Number(params.limit ?? limits.readMaxLines)));
|
|
989
|
+
const linesToFetch = Math.min(requestedLines, limits.readMaxLines) + 1;
|
|
990
|
+
const endLine = startLine + linesToFetch - 1;
|
|
991
|
+
const transferBytes = Math.min(DEFAULT_MAX_BYTES, limits.readMaxBytes + OUTPUT_FOOTER_RESERVE_BYTES + 4096);
|
|
992
|
+
const command = `sed -n '${startLine},${endLine}p' ${quote(path)} | head -c ${transferBytes}`;
|
|
993
|
+
const output = await withReconnect((client) => execRemote(client, command, false, DEFAULT_REMOTE_TIMEOUT_SECONDS));
|
|
994
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
995
|
+
if (!output.length && startLine > 1) throw new Error(`Offset ${startLine} is beyond end of remote file`);
|
|
996
|
+
const result = {
|
|
997
|
+
content: [{ type: "text", text: output.toString("utf8") }],
|
|
998
|
+
details: { remoteRange: { path, startLine, requestedLines: Math.min(requestedLines, limits.readMaxLines) } },
|
|
999
|
+
};
|
|
1000
|
+
return limitRemoteToolResult(result, "read", startLine, Math.min(requestedLines, limits.readMaxLines));
|
|
1001
|
+
};
|
|
1002
|
+
|
|
711
1003
|
const remoteWriteOps = (): WriteOperations => ({
|
|
712
1004
|
writeFile: (path, content) => withReconnect((client) => withSftp(client, (sftp) =>
|
|
713
1005
|
new Promise<void>((resolve, reject) => sftp.writeFile(mapPath(path), content, (error) => error ? reject(error) : resolve())))),
|
|
@@ -751,31 +1043,49 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
751
1043
|
const localWrite = createWriteTool(localCwd);
|
|
752
1044
|
const localEdit = createEditTool(localCwd);
|
|
753
1045
|
const localBash = createBashTool(localCwd);
|
|
754
|
-
pi.registerTool({
|
|
1046
|
+
pi.registerTool({
|
|
1047
|
+
...localRead,
|
|
1048
|
+
description: `Read file contents. Remote text reads fetch only the requested range and return at most ${DEFAULT_READ_MAX_LINES} lines or ${formatSize(DEFAULT_READ_MAX_BYTES)} by default; use offset/limit to continue.`,
|
|
1049
|
+
promptGuidelines: ["Use read with offset/limit for remote code and logs; inspect large files in focused chunks instead of reading them wholesale."],
|
|
1050
|
+
execute: (id, params, signal, update) => remote && routeRemoteTools
|
|
1051
|
+
? executeRemoteRead(id, params, signal, update)
|
|
1052
|
+
: localRead.execute(id, params, signal, update),
|
|
1053
|
+
});
|
|
755
1054
|
pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
|
|
756
1055
|
pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
|
|
757
|
-
pi.registerTool({
|
|
1056
|
+
pi.registerTool({
|
|
1057
|
+
...localBash,
|
|
1058
|
+
description: `Execute a shell command. Remote model-facing output returns at most the last ${DEFAULT_EXEC_MAX_LINES} lines or ${formatSize(DEFAULT_EXEC_MAX_BYTES)} by default; complete oversized output is saved locally.`,
|
|
1059
|
+
promptGuidelines: ["When using bash for remote logs and broad searches, use bounded commands such as tail, sed, or rg with limits instead of cat or unbounded find output."],
|
|
1060
|
+
execute: async (id, params, signal, update) => {
|
|
1061
|
+
if (!remote || !routeRemoteTools) return localBash.execute(id, params, signal, update);
|
|
1062
|
+
const result = await createBashTool(localCwd, { operations: remoteBashOps() }).execute(id, params, signal, update);
|
|
1063
|
+
return limitRemoteToolResult(result, "exec");
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
758
1066
|
|
|
759
1067
|
pi.registerTool({
|
|
760
1068
|
name: "ssh_remote_control",
|
|
761
1069
|
label: "SSH Remote Control",
|
|
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
|
|
763
|
-
promptSnippet: "Control the configured remote SSH connection, endpoint note, working directory, and local port forwarding",
|
|
1070
|
+
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 is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords are never accepted as arguments and are cached only in process memory.",
|
|
1071
|
+
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
|
|
764
1072
|
promptGuidelines: [
|
|
765
1073
|
"Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
766
1074
|
"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
1075
|
`Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
1076
|
+
"Keep ssh_remote_control exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
|
|
768
1077
|
"Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
|
|
769
1078
|
],
|
|
770
1079
|
parameters: Type.Object({
|
|
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" })),
|
|
1080
|
+
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
|
|
1081
|
+
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" })),
|
|
773
1082
|
note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
|
|
1083
|
+
memory: Type.Optional(Type.String({ description: "Persistent server-specific context for the memory action; omit or use an empty string to clear it" })),
|
|
774
1084
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
775
1085
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
776
1086
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
777
1087
|
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:
|
|
1088
|
+
displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_DISPLAY_LINES, description: "Collapsed visual lines for exec output; defaults to the /remote config display-lines setting (5 initially), maximum 50" })),
|
|
779
1089
|
}),
|
|
780
1090
|
async execute(_id, params, _signal, _update, ctx) {
|
|
781
1091
|
if (params.action === "status") {
|
|
@@ -813,16 +1123,24 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
813
1123
|
const resolved = await changeRemoteCwd(params.cwd, ctx);
|
|
814
1124
|
return { content: [{ type: "text", text: `Remote working directory: ${resolved}` }], details: { connected: true, cwd: resolved } };
|
|
815
1125
|
}
|
|
816
|
-
if (params.action === "note") {
|
|
1126
|
+
if (params.action === "note" || params.action === "memory") {
|
|
817
1127
|
const command = params.command || lastCommand || activeSshCommand();
|
|
818
1128
|
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
1129
|
const label = parseSshCommand(command).label;
|
|
1130
|
+
if (params.action === "note") {
|
|
1131
|
+
const note = params.note?.trim() || undefined;
|
|
1132
|
+
saveEndpointConfig(command, { note });
|
|
1133
|
+
if (remote && cacheId(remote) === cacheId(parseSshCommand(command)) && currentCtx) status(currentCtx);
|
|
1134
|
+
return {
|
|
1135
|
+
content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
|
|
1136
|
+
details: { endpoint: label, note },
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
const memory = params.memory?.trim() || undefined;
|
|
1140
|
+
saveEndpointConfig(command, { memory });
|
|
823
1141
|
return {
|
|
824
|
-
content: [{ type: "text", text:
|
|
825
|
-
details: { endpoint: label,
|
|
1142
|
+
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}).` }],
|
|
1143
|
+
details: { endpoint: label, memory },
|
|
826
1144
|
};
|
|
827
1145
|
}
|
|
828
1146
|
if (params.action === "exec") {
|
|
@@ -835,14 +1153,15 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
835
1153
|
}
|
|
836
1154
|
const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
|
|
837
1155
|
const timeoutSeconds = parseRemoteTimeout(params.timeout ?? DEFAULT_REMOTE_TIMEOUT_SECONDS);
|
|
838
|
-
const
|
|
1156
|
+
const limits = configuredOutputLimits();
|
|
1157
|
+
const formatted = await withReconnect((client) => execRemoteLimited(
|
|
839
1158
|
client,
|
|
840
1159
|
`cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`,
|
|
841
|
-
false,
|
|
842
1160
|
timeoutSeconds,
|
|
1161
|
+
limits.execMaxLines,
|
|
1162
|
+
limits.execMaxBytes,
|
|
843
1163
|
));
|
|
844
|
-
|
|
845
|
-
return {
|
|
1164
|
+
return limitRemoteToolResult({
|
|
846
1165
|
content: [{ type: "text", text: formatted.text }],
|
|
847
1166
|
details: {
|
|
848
1167
|
action: "exec",
|
|
@@ -850,10 +1169,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
850
1169
|
cwd: state.cwd,
|
|
851
1170
|
displayLines,
|
|
852
1171
|
output: formatted.content,
|
|
1172
|
+
modelLimited: true,
|
|
853
1173
|
truncation: formatted.truncation.truncated ? formatted.truncation : undefined,
|
|
854
1174
|
fullOutputPath: formatted.fullOutputPath,
|
|
855
1175
|
},
|
|
856
|
-
};
|
|
1176
|
+
}, "exec");
|
|
857
1177
|
}
|
|
858
1178
|
const command = params.command || lastCommand || activeSshCommand();
|
|
859
1179
|
if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
|
|
@@ -867,7 +1187,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
867
1187
|
});
|
|
868
1188
|
|
|
869
1189
|
pi.registerCommand("remote", {
|
|
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",
|
|
1190
|
+
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 | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
|
|
871
1191
|
handler: async (args, ctx) => {
|
|
872
1192
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
873
1193
|
const action = input.toLowerCase();
|
|
@@ -875,9 +1195,10 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
875
1195
|
const config = loadRemoteConfig();
|
|
876
1196
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
877
1197
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
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"}`;
|
|
1198
|
+
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"}`;
|
|
879
1199
|
});
|
|
880
|
-
|
|
1200
|
+
const limits = configuredOutputLimits(config);
|
|
1201
|
+
ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\nRead output: ${limits.readMaxLines} lines / ${formatSize(limits.readMaxBytes)}\nExec output: ${limits.execMaxLines} lines / ${formatSize(limits.execMaxBytes)}\nPer-turn output: ${formatSize(limits.turnMaxBytes)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
|
|
881
1202
|
return;
|
|
882
1203
|
}
|
|
883
1204
|
if (/^ssh\s+/i.test(input)) {
|
|
@@ -927,6 +1248,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
927
1248
|
ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
|
|
928
1249
|
return;
|
|
929
1250
|
}
|
|
1251
|
+
if (/^config\s+memory(?:\s+|$)/i.test(input)) {
|
|
1252
|
+
const value = input.replace(/^config\s+memory\s*/i, "").trim();
|
|
1253
|
+
if (!value) { ctx.ui.notify("Use /remote config memory TEXT or /remote config memory --clear", "error"); return; }
|
|
1254
|
+
const command = lastCommand || activeSshCommand();
|
|
1255
|
+
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
1256
|
+
const memory = value.toLowerCase() === "--clear" ? undefined : value;
|
|
1257
|
+
saveEndpointConfig(command, { memory });
|
|
1258
|
+
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");
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
930
1261
|
if (/^config\s+display-lines\s+/i.test(input)) {
|
|
931
1262
|
try {
|
|
932
1263
|
const displayLines = parseDisplayLines(input.replace(/^config\s+display-lines\s+/i, "").trim());
|
|
@@ -935,6 +1266,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
935
1266
|
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
936
1267
|
return;
|
|
937
1268
|
}
|
|
1269
|
+
const outputConfig = input.match(/^config\s+(read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes)\s+(\S+)$/i);
|
|
1270
|
+
if (outputConfig) {
|
|
1271
|
+
try {
|
|
1272
|
+
const key = outputConfig[1]!.toLowerCase();
|
|
1273
|
+
const value = outputConfig[2]!;
|
|
1274
|
+
const config = loadRemoteConfig();
|
|
1275
|
+
if (key === "read-max-lines") config.readMaxLines = parseOutputLines(value, "Read max lines");
|
|
1276
|
+
else if (key === "read-max-bytes") config.readMaxBytes = parseOutputBytes(value, "Read max bytes");
|
|
1277
|
+
else if (key === "exec-max-lines") config.execMaxLines = parseOutputLines(value, "Exec max lines");
|
|
1278
|
+
else if (key === "exec-max-bytes") config.execMaxBytes = parseOutputBytes(value, "Exec max bytes");
|
|
1279
|
+
else config.turnMaxBytes = parseOutputBytes(value, "Turn max bytes", DEFAULT_MAX_BYTES * 4);
|
|
1280
|
+
saveRemoteConfig(config);
|
|
1281
|
+
ctx.ui.notify(`SSH remote output limit updated: ${key}=${value}`, "info");
|
|
1282
|
+
} catch (error) { ctx.ui.notify((error as Error).message, "error"); }
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
938
1285
|
if (/^config\s+cwd\s+/i.test(input)) {
|
|
939
1286
|
const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
|
|
940
1287
|
const command = lastCommand || activeSshCommand();
|
|
@@ -985,13 +1332,14 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
985
1332
|
else timeoutSeconds = parseRemoteTimeout(option[2]);
|
|
986
1333
|
execInput = option[3]!;
|
|
987
1334
|
}
|
|
988
|
-
const
|
|
1335
|
+
const limits = configuredOutputLimits();
|
|
1336
|
+
const formatted = await withReconnect((client) => execRemoteLimited(
|
|
989
1337
|
client,
|
|
990
1338
|
`cd -- ${quote(state.cwd)} && ${execInput}`,
|
|
991
|
-
false,
|
|
992
1339
|
timeoutSeconds,
|
|
993
|
-
|
|
994
|
-
|
|
1340
|
+
limits.execMaxLines,
|
|
1341
|
+
limits.execMaxBytes,
|
|
1342
|
+
));
|
|
995
1343
|
const preview = previewRemoteOutput(formatted.content, displayLines);
|
|
996
1344
|
const omitted = formatted.truncation.totalLines > displayLines
|
|
997
1345
|
? `\n\n[Showing last ${Math.min(displayLines, formatted.truncation.totalLines)} of ${formatted.truncation.totalLines} lines]`
|
|
@@ -1027,6 +1375,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1027
1375
|
},
|
|
1028
1376
|
});
|
|
1029
1377
|
|
|
1378
|
+
pi.on("turn_start", () => { turnOutputBytes = 0; });
|
|
1030
1379
|
pi.on("session_start", async (event, ctx) => {
|
|
1031
1380
|
currentCtx = ctx;
|
|
1032
1381
|
status(ctx);
|
|
@@ -1055,9 +1404,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1055
1404
|
}
|
|
1056
1405
|
});
|
|
1057
1406
|
pi.on("before_agent_start", (event) => remote && routeRemoteTools ? {
|
|
1058
|
-
systemPrompt: event.systemPrompt
|
|
1059
|
-
`Current working directory: ${localCwd}`,
|
|
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.`,
|
|
1061
|
-
),
|
|
1407
|
+
systemPrompt: remoteSystemPrompt(event.systemPrompt, localCwd, remote),
|
|
1062
1408
|
} : undefined);
|
|
1409
|
+
pi.on("context", (event) => {
|
|
1410
|
+
if (!remote || !routeRemoteTools) return undefined;
|
|
1411
|
+
const content = serverMemoryContext(remote);
|
|
1412
|
+
if (!content) return undefined;
|
|
1413
|
+
return {
|
|
1414
|
+
messages: [...event.messages, {
|
|
1415
|
+
role: "custom",
|
|
1416
|
+
customType: "ssh-remote-server-memory",
|
|
1417
|
+
content,
|
|
1418
|
+
display: false,
|
|
1419
|
+
timestamp: Date.now(),
|
|
1420
|
+
}],
|
|
1421
|
+
};
|
|
1422
|
+
});
|
|
1063
1423
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ssh-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Persistent remote SSH workspaces for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Yutong Bian",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"index.ts",
|
|
16
16
|
"README.md",
|
|
17
17
|
"README.zh-CN.md",
|
|
18
|
+
"CHANGELOG.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|