pi-ssh-remote 0.1.10 → 0.1.12
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 +34 -1
- package/README.md +30 -11
- package/README.zh-CN.md +30 -12
- package/index.ts +159 -76
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,37 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
18
18
|
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [0.1.12] - 2026-08-25
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- None.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Simplified the server-memory documentation to focus on adding, listing, updating, and deleting entries without exposing implementation details.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Prevented follow-up SSH client errors during a connection loss before handshake from becoming an uncaught exception that exits Pi.
|
|
34
|
+
|
|
35
|
+
## [0.1.11] - 2026-08-16
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Added per-`user@host` server-memory JSON files with stable `id` and `content` entries, plus automatic migration from legacy string memories.
|
|
40
|
+
- Added a local-path exception so Pi's normal `read`, `edit`, and `write` tools can manage the active server's memory file while other operations remain routed over SSH.
|
|
41
|
+
- Added `/remote memory` to display the current server's memory entries, IDs, and JSON file path.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Replaced agent-side append/clear memory mutations with prompt-guided entry-level CRUD on the JSON file. Deletion instructions now require an explicit user request, an exact entry ID, and preservation of unrelated entries.
|
|
46
|
+
- Removed `/remote config memory`; the dedicated `/remote memory` command now handles display, while the `remote` tool's `memory` action locates and inspects the JSON file.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- None.
|
|
51
|
+
|
|
21
52
|
## [0.1.10] - 2026-08-16
|
|
22
53
|
|
|
23
54
|
### Added
|
|
@@ -189,7 +220,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
189
220
|
|
|
190
221
|
- None.
|
|
191
222
|
|
|
192
|
-
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.
|
|
223
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.12...HEAD
|
|
224
|
+
[0.1.12]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...v0.1.12
|
|
225
|
+
[0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
|
|
193
226
|
[0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
|
|
194
227
|
[0.1.9]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...v0.1.9
|
|
195
228
|
[0.1.8]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...v0.1.8
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The extension exposes `remote` for the agent and `/remote` for the user. You can
|
|
|
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.
|
|
34
|
+
Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible.
|
|
35
35
|
|
|
36
36
|
### Resilient and bounded by default
|
|
37
37
|
|
|
@@ -66,7 +66,6 @@ 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.
|
|
70
69
|
/remote status
|
|
71
70
|
```
|
|
72
71
|
|
|
@@ -82,6 +81,29 @@ Return to local tools with:
|
|
|
82
81
|
/remote off
|
|
83
82
|
```
|
|
84
83
|
|
|
84
|
+
### Server memory
|
|
85
|
+
|
|
86
|
+
Tell Pi what to remember for the current server:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Remember for this server: use /opt/conda/bin/python and never stop shared jobs.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
List saved entries and their IDs:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
/remote memory
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Use an ID to update or delete an entry:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
Update memory deploy-command to use /opt/deploy/v2/run.sh.
|
|
102
|
+
Delete memory obsolete-proxy-rule.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pi automatically applies saved memory when you work on that server. Deletion only happens when you explicitly request it.
|
|
106
|
+
|
|
85
107
|
### Private key authentication
|
|
86
108
|
|
|
87
109
|
Pass an explicit local private key with `-i`:
|
|
@@ -131,7 +153,7 @@ The default foreground timeout prevents an accidental long-running command from
|
|
|
131
153
|
/remote
|
|
132
154
|
```
|
|
133
155
|
|
|
134
|
-
Endpoint notes and working directories
|
|
156
|
+
Endpoint notes and working directories survive Pi restarts and remain separate for each saved endpoint.
|
|
135
157
|
|
|
136
158
|
### 4. Expose a remote service while editing locally
|
|
137
159
|
|
|
@@ -164,8 +186,6 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
|
|
|
164
186
|
tools on the local repository.
|
|
165
187
|
```
|
|
166
188
|
|
|
167
|
-
These requests are handled through the agent-facing `remote` tool; slash commands are optional. For safety, calling its `memory` action without a `memory` argument only reads the current server memory. Non-empty text is appended to the existing memory instead of replacing it; retrying the same final entry is a no-op. Clearing still requires the explicit `--clear` value.
|
|
168
|
-
|
|
169
189
|
## Command reference
|
|
170
190
|
|
|
171
191
|
| Command | Purpose |
|
|
@@ -176,8 +196,7 @@ These requests are handled through the agent-facing `remote` tool; slash command
|
|
|
176
196
|
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
177
197
|
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
178
198
|
| `/remote config note --clear` | Clear its note |
|
|
179
|
-
| `/remote
|
|
180
|
-
| `/remote config memory --clear` | Clear the current `user@host` memory |
|
|
199
|
+
| `/remote memory` | List the current server's memory entries and IDs |
|
|
181
200
|
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
182
201
|
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
183
202
|
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
@@ -205,7 +224,7 @@ Endpoint configuration is stored locally in:
|
|
|
205
224
|
~/.pi/agent/ssh-remote-config.json
|
|
206
225
|
```
|
|
207
226
|
|
|
208
|
-
Saved global values include endpoints, active endpoint, notes,
|
|
227
|
+
Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Private key contents, passwords, and key passphrases are **never written to this file**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
|
|
209
228
|
|
|
210
229
|
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.
|
|
211
230
|
|
|
@@ -215,13 +234,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
|
|
|
215
234
|
|
|
216
235
|
## Releases
|
|
217
236
|
|
|
218
|
-
Latest release: [v0.1.
|
|
237
|
+
Latest release: [v0.1.12](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.12)
|
|
219
238
|
|
|
220
239
|
| Version | Date | Highlights |
|
|
221
240
|
|---|---|---|
|
|
241
|
+
| [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | Safe handling of repeated SSH errors when a connection is lost during handshake |
|
|
242
|
+
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
|
|
222
243
|
| [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | Safe inspect-by-default and append-only agent updates for server memory |
|
|
223
|
-
| [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | Stable deduplicated server-memory context and shorter footer labels |
|
|
224
|
-
| [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | Explicit private-key login, cross-port server memory, and the shorter `remote` tool name |
|
|
225
244
|
|
|
226
245
|
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
227
246
|
|
package/README.zh-CN.md
CHANGED
|
@@ -54,7 +54,7 @@ H100 训练机 (root@gpu-box.example.com:2202):/srv/project
|
|
|
54
54
|
- 服务器特定记忆;
|
|
55
55
|
- 端口转发配置。
|
|
56
56
|
|
|
57
|
-
例如可以把几台机器分别备注为 `8xH100
|
|
57
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。这些配置保存在本地,重启 Pi 后仍然存在。
|
|
58
58
|
|
|
59
59
|
### 断线后可以自动恢复
|
|
60
60
|
|
|
@@ -107,7 +107,6 @@ pi install npm:pi-ssh-remote
|
|
|
107
107
|
```text
|
|
108
108
|
/remote cd /srv/project
|
|
109
109
|
/remote config note H100 训练机
|
|
110
|
-
/remote config memory 使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
|
|
111
110
|
```
|
|
112
111
|
|
|
113
112
|
查看当前状态:
|
|
@@ -124,6 +123,29 @@ pi install npm:pi-ssh-remote
|
|
|
124
123
|
/remote off
|
|
125
124
|
```
|
|
126
125
|
|
|
126
|
+
### 服务器记忆
|
|
127
|
+
|
|
128
|
+
直接告诉 Pi 要为当前服务器记住什么:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
记住这台服务器使用 /opt/conda/bin/python,不要停止共享任务。
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
查看已有记忆和 ID:
|
|
135
|
+
|
|
136
|
+
```text
|
|
137
|
+
/remote memory
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
通过 ID 修改或删除:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
把记忆 deploy-command 改为使用 /opt/deploy/v2/run.sh。
|
|
144
|
+
删除记忆 obsolete-proxy-rule。
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
以后连接这台服务器时,Pi 会自动使用这些记忆。只有明确要求删除时才会删除。
|
|
148
|
+
|
|
127
149
|
### 使用私钥文件登录
|
|
128
150
|
|
|
129
151
|
通过 `-i` 指定本地私钥:
|
|
@@ -183,7 +205,7 @@ pi install npm:pi-ssh-remote
|
|
|
183
205
|
/remote
|
|
184
206
|
```
|
|
185
207
|
|
|
186
|
-
备注和默认目录会按 `user@host:port`
|
|
208
|
+
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
|
|
187
209
|
|
|
188
210
|
### 4. 远端启动模型,本地开发界面
|
|
189
211
|
|
|
@@ -218,8 +240,6 @@ pi install npm:pi-ssh-remote
|
|
|
218
240
|
把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
|
|
219
241
|
```
|
|
220
242
|
|
|
221
|
-
Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删,调用 `memory` action 但不传 `memory` 参数时只会读取当前服务器记忆;传入非空内容时会追加到现有记忆,而不是覆盖,重复提交相同的末尾内容不会再次追加;清除时仍必须显式传入 `--clear`。
|
|
222
|
-
|
|
223
243
|
## 命令说明
|
|
224
244
|
|
|
225
245
|
| 命令 | 作用 |
|
|
@@ -230,8 +250,7 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
230
250
|
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
231
251
|
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
232
252
|
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
233
|
-
| `/remote
|
|
234
|
-
| `/remote config memory --clear` | 清除当前 `user@host` 的记忆 |
|
|
253
|
+
| `/remote memory` | 展示当前服务器的记忆条目和 ID |
|
|
235
254
|
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
236
255
|
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
237
256
|
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
@@ -264,14 +283,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
264
283
|
- 已保存的服务器;
|
|
265
284
|
- 当前选中的服务器;
|
|
266
285
|
- 服务器备注;
|
|
267
|
-
- 服务器特定记忆;
|
|
268
286
|
- 默认远程目录;
|
|
269
287
|
- 端口转发配置;
|
|
270
288
|
- 私钥文件路径;
|
|
271
289
|
- 命令预览设置;
|
|
272
290
|
- 模型输出预算。
|
|
273
291
|
|
|
274
|
-
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session
|
|
292
|
+
此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。
|
|
275
293
|
|
|
276
294
|
私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
|
|
277
295
|
|
|
@@ -294,13 +312,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
294
312
|
|
|
295
313
|
## 版本发布
|
|
296
314
|
|
|
297
|
-
最新版本:[v0.1.
|
|
315
|
+
最新版本:[v0.1.12](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.12)
|
|
298
316
|
|
|
299
317
|
| 版本 | 日期 | 主要内容 |
|
|
300
318
|
|---|---|---|
|
|
319
|
+
| [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | 安全处理 SSH 握手期间断线所触发的连续错误 |
|
|
320
|
+
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
|
|
301
321
|
| [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
|
|
302
|
-
| [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | 稳定且去重的服务器记忆上下文与更短的状态栏标签 |
|
|
303
|
-
| [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | 显式私钥登录、跨端口服务器记忆与更短的 `remote` 工具名 |
|
|
304
322
|
|
|
305
323
|
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
306
324
|
|
package/index.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import ssh2, { type Client as SshClient, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
|
10
|
-
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
-
import { dirname, isAbsolute, join, posix, relative, sep } from "node:path";
|
|
10
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
11
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, 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";
|
|
@@ -64,9 +64,20 @@ interface RemoteEndpointConfig {
|
|
|
64
64
|
memory?: string;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface ServerMemoryEntry {
|
|
68
|
+
id: string;
|
|
69
|
+
content: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ServerMemoryFile {
|
|
73
|
+
server: string;
|
|
74
|
+
entries: ServerMemoryEntry[];
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
interface RemoteConfig {
|
|
68
78
|
activeEndpoint?: string;
|
|
69
79
|
endpoints?: Record<string, RemoteEndpointConfig>;
|
|
80
|
+
/** Legacy values migrated into per-server JSON files at extension startup. */
|
|
70
81
|
serverMemories?: Record<string, string>;
|
|
71
82
|
displayLines?: number;
|
|
72
83
|
readMaxLines?: number;
|
|
@@ -98,6 +109,7 @@ interface SessionRemoteState {
|
|
|
98
109
|
const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
|
|
99
110
|
const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
|
|
100
111
|
const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
|
|
112
|
+
const SERVER_MEMORY_DIR = join(AGENT_DIR, "ssh-remote-memories");
|
|
101
113
|
const FALLBACK_REMOTE_CWD = "~";
|
|
102
114
|
const DEFAULT_DISPLAY_LINES = 5;
|
|
103
115
|
const MAX_DISPLAY_LINES = 50;
|
|
@@ -412,21 +424,103 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
|
|
|
412
424
|
return note ? `${note} (${endpoint.label})` : endpoint.label;
|
|
413
425
|
}
|
|
414
426
|
|
|
415
|
-
function
|
|
416
|
-
|
|
427
|
+
function serverMemoryFilePath(endpoint: ParsedSsh | string): string {
|
|
428
|
+
const server = typeof endpoint === "string" ? endpoint : serverMemoryId(endpoint);
|
|
429
|
+
return join(SERVER_MEMORY_DIR, `${Buffer.from(server, "utf8").toString("base64url")}.json`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function legacyMemoryEntries(memory: string): ServerMemoryEntry[] {
|
|
433
|
+
return memory
|
|
434
|
+
.trim()
|
|
435
|
+
.split(/\n\s*\n/)
|
|
436
|
+
.map((content) => content.trim())
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.map((content, index) => ({ id: `legacy-${String(index + 1).padStart(3, "0")}`, content }));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function writeServerMemoryFile(path: string, memory: ServerMemoryFile): void {
|
|
442
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
443
|
+
writeFileSync(path, JSON.stringify(memory, null, 2) + "\n", { mode: 0o600 });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function migrateLegacyServerMemories(): void {
|
|
447
|
+
const config = loadRemoteConfig();
|
|
448
|
+
const legacy = config.serverMemories ?? {};
|
|
449
|
+
if (!Object.keys(legacy).length) {
|
|
450
|
+
mkdirSync(SERVER_MEMORY_DIR, { recursive: true });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const remaining = { ...legacy };
|
|
455
|
+
let changed = false;
|
|
456
|
+
for (const [server, content] of Object.entries(legacy)) {
|
|
457
|
+
const path = serverMemoryFilePath(server);
|
|
458
|
+
try {
|
|
459
|
+
if (!existsSync(path)) {
|
|
460
|
+
writeServerMemoryFile(path, { server, entries: legacyMemoryEntries(content) });
|
|
461
|
+
}
|
|
462
|
+
delete remaining[server];
|
|
463
|
+
changed = true;
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
if (changed) saveRemoteConfig({ ...config, serverMemories: remaining });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function ensureServerMemoryFile(endpoint: ParsedSsh): string {
|
|
470
|
+
const path = serverMemoryFilePath(endpoint);
|
|
471
|
+
if (!existsSync(path)) writeServerMemoryFile(path, { server: serverMemoryId(endpoint), entries: [] });
|
|
472
|
+
return path;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function loadServerMemory(endpoint: ParsedSsh): ServerMemoryFile | undefined {
|
|
476
|
+
const path = serverMemoryFilePath(endpoint);
|
|
477
|
+
if (!existsSync(path)) return undefined;
|
|
478
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ServerMemoryFile>;
|
|
479
|
+
if (parsed.server !== serverMemoryId(endpoint) || !Array.isArray(parsed.entries)) {
|
|
480
|
+
throw new Error("expected an object with the matching server and an entries array");
|
|
481
|
+
}
|
|
482
|
+
const ids = new Set<string>();
|
|
483
|
+
const entries = parsed.entries.map((entry, index) => {
|
|
484
|
+
if (!entry || typeof entry.id !== "string" || !entry.id.trim() || typeof entry.content !== "string" || !entry.content.trim()) {
|
|
485
|
+
throw new Error(`entry ${index + 1} must contain non-empty string id and content fields`);
|
|
486
|
+
}
|
|
487
|
+
const id = entry.id.trim();
|
|
488
|
+
if (ids.has(id)) throw new Error(`duplicate entry id: ${id}`);
|
|
489
|
+
ids.add(id);
|
|
490
|
+
return { id, content: entry.content.trim() };
|
|
491
|
+
});
|
|
492
|
+
return { server: parsed.server, entries };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function formatServerMemoryEntries(memory: ServerMemoryFile | undefined): string {
|
|
496
|
+
if (!memory?.entries.length) return "No entries.";
|
|
497
|
+
return memory.entries.map((entry) => `[${entry.id}]\n${entry.content}`).join("\n\n");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function memoryManagementPrompt(remote: RemoteState): string {
|
|
501
|
+
let path = serverMemoryFilePath(remote);
|
|
502
|
+
try { path = ensureServerMemoryFile(remote); }
|
|
503
|
+
catch {}
|
|
504
|
+
return `Persistent memory for this SSH server is a local JSON file at ${path}. This exact path is always handled by Pi's local read, write, and edit tools even while other tools are routed over SSH. The file schema is {"server":"${serverMemoryId(remote)}","entries":[{"id":"stable-unique-id","content":"memory text"}]}. If the file does not exist, create it with that server value and an empty entries array. Read the file before changing it and preserve valid JSON plus all unrelated entries. Add by appending one object with a unique stable id; query by reading the file; update by editing only the matching id. DELETE SAFETY: delete an entry only when the user explicitly asks to delete, remove, or forget server memory. Before deleting, read the file and identify the exact id; if the target is ambiguous, ask the user. Use edit to remove only that exact object and preserve every other entry. Never treat a correction or replacement request as permission to delete, and never delete all entries unless the user explicitly requests deletion of all server memory.`;
|
|
417
505
|
}
|
|
418
506
|
|
|
419
507
|
function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
|
|
420
|
-
|
|
508
|
+
const workspacePrompt = systemPrompt.replace(
|
|
421
509
|
`Current working directory: ${localCwd}`,
|
|
422
|
-
`Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use remote with action disconnect to return to the local environment when requested.`,
|
|
510
|
+
`Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server, except for the explicitly identified local server-memory JSON file. Use remote with action disconnect to return to the local environment when requested.`,
|
|
423
511
|
);
|
|
512
|
+
return `${workspacePrompt}\n\n${memoryManagementPrompt(remote)}`;
|
|
424
513
|
}
|
|
425
514
|
|
|
426
515
|
function serverMemoryContext(remote: RemoteState): string | undefined {
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
516
|
+
const path = serverMemoryFilePath(remote);
|
|
517
|
+
let memory: ServerMemoryFile | undefined;
|
|
518
|
+
try { memory = loadServerMemory(remote); }
|
|
519
|
+
catch (error) {
|
|
520
|
+
return `<ssh_remote_server_memory endpoint="${remote.label}" path="${path}">\nThe server-memory JSON file is invalid and must not be applied until repaired: ${(error as Error).message}\n</ssh_remote_server_memory>`;
|
|
521
|
+
}
|
|
522
|
+
if (!memory?.entries.length) return undefined;
|
|
523
|
+
return `<ssh_remote_server_memory endpoint="${remote.label}" path="${path}">\nThe following user-configured JSON entries are persistent memory specific to this SSH server. Apply their content while working on this server:\n${JSON.stringify(memory.entries, null, 2)}\n</ssh_remote_server_memory>`;
|
|
430
524
|
}
|
|
431
525
|
|
|
432
526
|
function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
|
|
@@ -442,23 +536,6 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
|
|
|
442
536
|
});
|
|
443
537
|
}
|
|
444
538
|
|
|
445
|
-
function saveServerMemory(command: string, memory: string | undefined): void {
|
|
446
|
-
const config = loadRemoteConfig();
|
|
447
|
-
const parsed = parseSshCommand(command);
|
|
448
|
-
const endpointKey = cacheId(parsed);
|
|
449
|
-
const memoryKey = serverMemoryId(parsed);
|
|
450
|
-
const serverMemories = { ...(config.serverMemories ?? {}) };
|
|
451
|
-
if (memory) serverMemories[memoryKey] = memory;
|
|
452
|
-
else delete serverMemories[memoryKey];
|
|
453
|
-
saveRemoteConfig({
|
|
454
|
-
...config,
|
|
455
|
-
serverMemories,
|
|
456
|
-
endpoints: {
|
|
457
|
-
...(config.endpoints ?? {}),
|
|
458
|
-
[endpointKey]: { ...endpointConfig(config, command), sshCommand: command },
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
539
|
|
|
463
540
|
function loadKnownHosts(): Record<string, string> {
|
|
464
541
|
try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
|
|
@@ -649,7 +726,11 @@ function connect(config: ParsedSsh, authentication: SshAuthentication, fingerpri
|
|
|
649
726
|
hostVerifier: (hash) => hash === fingerprint,
|
|
650
727
|
};
|
|
651
728
|
client.once("ready", () => resolve(client));
|
|
652
|
-
|
|
729
|
+
// ssh2 may emit a socket error followed by a protocol error while a
|
|
730
|
+
// connection is lost during handshake. Keep consuming client errors after
|
|
731
|
+
// the first one so EventEmitter does not turn the follow-up into an
|
|
732
|
+
// uncaught exception; rejecting an already-settled promise is a no-op.
|
|
733
|
+
client.on("error", reject);
|
|
653
734
|
client.connect(options);
|
|
654
735
|
});
|
|
655
736
|
}
|
|
@@ -782,6 +863,8 @@ async function askPassword(ctx: any): Promise<string | null> {
|
|
|
782
863
|
}
|
|
783
864
|
|
|
784
865
|
export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
866
|
+
migrateLegacyServerMemories();
|
|
867
|
+
|
|
785
868
|
const localCwd = process.cwd();
|
|
786
869
|
let remote: RemoteState | null = null;
|
|
787
870
|
let routeRemoteTools = false;
|
|
@@ -1270,16 +1353,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1270
1353
|
const localWrite = createWriteTool(localCwd);
|
|
1271
1354
|
const localEdit = createEditTool(localCwd);
|
|
1272
1355
|
const localBash = createBashTool(localCwd);
|
|
1356
|
+
const targetsLocalServerMemory = (path: unknown): boolean => {
|
|
1357
|
+
if (!remote || typeof path !== "string") return false;
|
|
1358
|
+
return resolve(path.replace(/^@/, "")) === serverMemoryFilePath(remote);
|
|
1359
|
+
};
|
|
1273
1360
|
pi.registerTool({
|
|
1274
1361
|
...localRead,
|
|
1275
1362
|
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.`,
|
|
1276
1363
|
promptGuidelines: ["Use read with offset/limit for remote code and logs; inspect large files in focused chunks instead of reading them wholesale."],
|
|
1277
|
-
execute: (id, params, signal, update) => remote && routeRemoteTools
|
|
1364
|
+
execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path)
|
|
1278
1365
|
? executeRemoteRead(id, params, signal, update)
|
|
1279
1366
|
: localRead.execute(id, params, signal, update),
|
|
1280
1367
|
});
|
|
1281
|
-
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) });
|
|
1282
|
-
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) });
|
|
1368
|
+
pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path) ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
|
|
1369
|
+
pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path) ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
|
|
1283
1370
|
pi.registerTool({
|
|
1284
1371
|
...localBash,
|
|
1285
1372
|
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.`,
|
|
@@ -1294,12 +1381,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1294
1381
|
pi.registerTool({
|
|
1295
1382
|
name: "remote",
|
|
1296
1383
|
label: "Remote",
|
|
1297
|
-
description: "Connect, reconnect, annotate endpoints,
|
|
1298
|
-
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
|
|
1384
|
+
description: "Connect, reconnect, annotate endpoints, locate server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Server memory is managed as JSON entries with Pi's read, write, and edit tools. Connections support SSH agent, password, or an explicit local private key with -i. 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 and key passphrases are never accepted as arguments and are cached only in process memory.",
|
|
1385
|
+
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory location, working directory, and local port forwarding",
|
|
1299
1386
|
promptGuidelines: [
|
|
1300
1387
|
"Use remote when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
1301
1388
|
"Use remote 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.",
|
|
1302
|
-
"Use remote with action memory
|
|
1389
|
+
"Use remote with action memory to locate and inspect the current server-memory JSON file, then use read/edit/write on that exact local path for entry-level changes.",
|
|
1390
|
+
"Delete a server-memory JSON entry only after an explicit user request to delete, remove, or forget it. Read the file first, identify the exact entry id, and remove only that object with edit; ask the user if the target is ambiguous and never infer deletion from an update request.",
|
|
1303
1391
|
`Always set timeout for remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
1304
1392
|
"Keep remote exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
|
|
1305
1393
|
"Use remote with action disconnect after remote work when the user asks to return to the local environment.",
|
|
@@ -1308,7 +1396,6 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1308
1396
|
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
|
|
1309
1397
|
command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22 or ssh -i ~/.ssh/id_ed25519 root@host; optionally selects the endpoint for note or memory" })),
|
|
1310
1398
|
note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
|
|
1311
|
-
memory: Type.Optional(Type.String({ description: "Persistent context shared by the endpoint's user@host across SSH ports; omit to inspect, provide non-empty text to append without overwriting existing memory, or use --clear to clear explicitly" })),
|
|
1312
1399
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
1313
1400
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
1314
1401
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
@@ -1357,47 +1444,29 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1357
1444
|
if (params.action === "note" || params.action === "memory") {
|
|
1358
1445
|
const command = params.command || lastCommand || activeSshCommand();
|
|
1359
1446
|
if (!command) throw new Error("No SSH endpoint configured; connect or select an endpoint first");
|
|
1360
|
-
const
|
|
1447
|
+
const parsed = parseSshCommand(command);
|
|
1448
|
+
const label = parsed.label;
|
|
1361
1449
|
if (params.action === "note") {
|
|
1362
1450
|
const note = params.note?.trim() || undefined;
|
|
1363
1451
|
saveEndpointConfig(command, { note });
|
|
1364
|
-
if (remote && cacheId(remote) === cacheId(
|
|
1452
|
+
if (remote && cacheId(remote) === cacheId(parsed) && currentCtx) status(currentCtx);
|
|
1365
1453
|
return {
|
|
1366
1454
|
content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
|
|
1367
1455
|
details: { endpoint: label, note },
|
|
1368
1456
|
};
|
|
1369
1457
|
}
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
content: [{ type: "text", text: memory ? `SSH remote server memory (${server}):\n${memory}` : `No SSH remote server memory is configured (${server}).` }],
|
|
1376
|
-
details: { endpoint: label, server, memory },
|
|
1377
|
-
};
|
|
1458
|
+
const path = ensureServerMemoryFile(parsed);
|
|
1459
|
+
let memory: ServerMemoryFile | undefined;
|
|
1460
|
+
try { memory = loadServerMemory(parsed); }
|
|
1461
|
+
catch (error) {
|
|
1462
|
+
throw new Error(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`);
|
|
1378
1463
|
}
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
saveServerMemory(command, undefined);
|
|
1383
|
-
return {
|
|
1384
|
-
content: [{ type: "text", text: `SSH remote server memory cleared (${server}).` }],
|
|
1385
|
-
details: { endpoint: label, server, memory: undefined },
|
|
1386
|
-
};
|
|
1387
|
-
}
|
|
1388
|
-
const currentMemory = endpointMemory(parsed);
|
|
1389
|
-
const duplicate = currentMemory === addition || currentMemory?.endsWith(`\n\n${addition}`);
|
|
1390
|
-
if (duplicate) {
|
|
1391
|
-
return {
|
|
1392
|
-
content: [{ type: "text", text: `SSH remote server memory already ends with this content (${server}); nothing was changed.` }],
|
|
1393
|
-
details: { endpoint: label, server, memory: currentMemory, addedMemory: addition, changed: false },
|
|
1394
|
-
};
|
|
1395
|
-
}
|
|
1396
|
-
const memory = currentMemory ? `${currentMemory}\n\n${addition}` : addition;
|
|
1397
|
-
saveServerMemory(command, memory);
|
|
1464
|
+
const text = memory?.entries.length
|
|
1465
|
+
? `SSH remote server memory (${serverMemoryId(parsed)}) is stored at ${path}:\n${JSON.stringify(memory.entries, null, 2)}`
|
|
1466
|
+
: `No server-memory entries are configured for ${serverMemoryId(parsed)}. Use write to create ${path} with schema {"server":"${serverMemoryId(parsed)}","entries":[]}, then use edit for entry-level changes.`;
|
|
1398
1467
|
return {
|
|
1399
|
-
content: [{ type: "text", text
|
|
1400
|
-
details: { endpoint: label, server,
|
|
1468
|
+
content: [{ type: "text", text }],
|
|
1469
|
+
details: { endpoint: label, server: serverMemoryId(parsed), path, entries: memory?.entries ?? [] },
|
|
1401
1470
|
};
|
|
1402
1471
|
}
|
|
1403
1472
|
if (params.action === "exec") {
|
|
@@ -1444,7 +1513,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1444
1513
|
});
|
|
1445
1514
|
|
|
1446
1515
|
pi.registerCommand("remote", {
|
|
1447
|
-
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] [-i KEY] | config | use USER@HOST:PORT | config note TEXT|--clear | config
|
|
1516
|
+
description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] [-i KEY] | memory | config | use USER@HOST:PORT | config note 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",
|
|
1448
1517
|
handler: async (args, ctx) => {
|
|
1449
1518
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
1450
1519
|
const action = input.toLowerCase();
|
|
@@ -1453,8 +1522,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1453
1522
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
1454
1523
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
1455
1524
|
const command = endpoint.sshCommand || commandFromEndpointKey(key);
|
|
1456
|
-
|
|
1457
|
-
|
|
1525
|
+
let memory = "none";
|
|
1526
|
+
if (command) {
|
|
1527
|
+
const parsed = parseSshCommand(command);
|
|
1528
|
+
const path = serverMemoryFilePath(parsed);
|
|
1529
|
+
try {
|
|
1530
|
+
const entries = loadServerMemory(parsed)?.entries ?? [];
|
|
1531
|
+
memory = `${entries.length} JSON entr${entries.length === 1 ? "y" : "ies"} (${path})`;
|
|
1532
|
+
} catch (error) {
|
|
1533
|
+
memory = `invalid JSON (${path}: ${(error as Error).message})`;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${memory}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
|
|
1458
1537
|
});
|
|
1459
1538
|
const limits = configuredOutputLimits(config);
|
|
1460
1539
|
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");
|
|
@@ -1508,15 +1587,19 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1508
1587
|
ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
|
|
1509
1588
|
return;
|
|
1510
1589
|
}
|
|
1511
|
-
if (
|
|
1512
|
-
const
|
|
1513
|
-
if (!value) { ctx.ui.notify("Use /remote config memory TEXT or /remote config memory --clear", "error"); return; }
|
|
1514
|
-
const command = lastCommand || activeSshCommand();
|
|
1590
|
+
if (action === "memory") {
|
|
1591
|
+
const command = remote?.command || lastCommand || activeSshCommand();
|
|
1515
1592
|
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
ctx.ui.notify(
|
|
1593
|
+
const parsed = parseSshCommand(command);
|
|
1594
|
+
let path: string;
|
|
1595
|
+
try { path = ensureServerMemoryFile(parsed); }
|
|
1596
|
+
catch (error) { ctx.ui.notify(`Could not initialize server-memory JSON: ${(error as Error).message}`, "error"); return; }
|
|
1597
|
+
try {
|
|
1598
|
+
const memory = loadServerMemory(parsed);
|
|
1599
|
+
ctx.ui.notify(`SSH remote server memory (${serverMemoryId(parsed)})\nFile: ${path}\n\n${formatServerMemoryEntries(memory)}`, "info");
|
|
1600
|
+
} catch (error) {
|
|
1601
|
+
ctx.ui.notify(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`, "error");
|
|
1602
|
+
}
|
|
1520
1603
|
return;
|
|
1521
1604
|
}
|
|
1522
1605
|
if (/^config\s+display-lines\s+/i.test(input)) {
|