pi-ssh-remote 0.1.10 → 0.1.11
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 +19 -1
- package/LICENSE +0 -0
- package/README.md +26 -9
- package/README.zh-CN.md +37 -11
- package/index.ts +154 -75
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
18
18
|
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [0.1.11] - 2026-08-16
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Added per-`user@host` server-memory JSON files with stable `id` and `content` entries, plus automatic migration from legacy string memories.
|
|
26
|
+
- 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.
|
|
27
|
+
- Added `/remote memory` to display the current server's memory entries, IDs, and JSON file path.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- None.
|
|
37
|
+
|
|
21
38
|
## [0.1.10] - 2026-08-16
|
|
22
39
|
|
|
23
40
|
### Added
|
|
@@ -189,7 +206,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
|
|
|
189
206
|
|
|
190
207
|
- None.
|
|
191
208
|
|
|
192
|
-
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.
|
|
209
|
+
[Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...HEAD
|
|
210
|
+
[0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
|
|
193
211
|
[0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
|
|
194
212
|
[0.1.9]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...v0.1.9
|
|
195
213
|
[0.1.8]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...v0.1.8
|
package/LICENSE
CHANGED
|
File without changes
|
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. Server memory is
|
|
34
|
+
Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Server memory is a per-`user@host` JSON file containing stable `{ "id", "content" }` entries, so another SSH port for the same user and host uses the same entries. Pi is told the exact local file path and can use its normal `read`, `edit`, and `write` tools for entry-level CRUD even while other tools are routed remotely. 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. Valid server-memory entries are automatically injected into the model context whenever a matching endpoint is connected as the active remote workspace.
|
|
35
35
|
|
|
36
36
|
### Resilient and bounded by default
|
|
37
37
|
|
|
@@ -66,8 +66,9 @@ 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
|
|
70
|
+
|
|
71
|
+
You: Add a server-memory entry saying to use /opt/conda/bin/python and never stop shared jobs.
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
|
|
@@ -131,7 +132,7 @@ The default foreground timeout prevents an accidental long-running command from
|
|
|
131
132
|
/remote
|
|
132
133
|
```
|
|
133
134
|
|
|
134
|
-
Endpoint notes and working directories
|
|
135
|
+
Endpoint notes and working directories survive Pi restarts in the local remote configuration. `user@host`-specific memory survives in a separate local JSON file under `~/.pi/agent/ssh-remote-memories/`; each entry has a stable `id` and `content`. When Pi connects to an endpoint, valid entries matching its user and host are added to the model context on every agent run while remote tool routing remains active; the SSH port does not affect memory selection. Disconnecting or switching to tunnel-only mode removes them from subsequent model requests.
|
|
135
136
|
|
|
136
137
|
### 4. Expose a remote service while editing locally
|
|
137
138
|
|
|
@@ -164,7 +165,24 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
|
|
|
164
165
|
tools on the local repository.
|
|
165
166
|
```
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
```text
|
|
169
|
+
Add a server-memory entry that says deployments must use /opt/deploy/run.sh.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
List the current server's entries and IDs before referring to one:
|
|
173
|
+
|
|
174
|
+
```text
|
|
175
|
+
/remote memory
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Then update or delete by the displayed ID:
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
Update the entry with ID deploy-command to use /opt/deploy/v2/run.sh.
|
|
182
|
+
Delete the server-memory entry with ID obsolete-proxy-rule.
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
These requests are handled through the agent-facing `remote` tool; slash commands are optional. The `memory` action locates and inspects the matching JSON file, while Pi's normal `read`, `edit`, and `write` tools perform entry-level add, query, update, and delete operations. The injected instructions make deletion deliberately strict: Pi must first read the file, identify the exact entry ID, and delete only that object—and only after the user explicitly asks to delete, remove, or forget memory. Ambiguous targets require clarification, update requests do not imply deletion, and deleting all entries requires an explicit request to delete all server memory.
|
|
168
186
|
|
|
169
187
|
## Command reference
|
|
170
188
|
|
|
@@ -176,8 +194,7 @@ These requests are handled through the agent-facing `remote` tool; slash command
|
|
|
176
194
|
| `/remote use USER@HOST:PORT` | Select a saved endpoint |
|
|
177
195
|
| `/remote config note TEXT` | Persist a note for the selected endpoint |
|
|
178
196
|
| `/remote config note --clear` | Clear its note |
|
|
179
|
-
| `/remote
|
|
180
|
-
| `/remote config memory --clear` | Clear the current `user@host` memory |
|
|
197
|
+
| `/remote memory` | List the current server's memory entries, IDs, and JSON file path |
|
|
181
198
|
| `/remote config cwd PATH` | Persist its default remote working directory |
|
|
182
199
|
| `/remote cd PATH` | Change the connected remote cwd and persist it |
|
|
183
200
|
| `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
|
|
@@ -205,7 +222,7 @@ Endpoint configuration is stored locally in:
|
|
|
205
222
|
~/.pi/agent/ssh-remote-config.json
|
|
206
223
|
```
|
|
207
224
|
|
|
208
|
-
Saved global values include endpoints, active endpoint, notes,
|
|
225
|
+
Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Server-memory entries are stored separately under `~/.pi/agent/ssh-remote-memories/` as `{ "server", "entries": [{ "id", "content" }] }`; legacy string memories are migrated automatically. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Server memory is user-configured trusted context and is inserted into each model request only while a matching endpoint is the active remote workspace; it is not read from the remote server. The current server's exact memory path is the only local path that the routed `read`, `edit`, and `write` tools special-case. Private key contents, passwords, and key passphrases are **never written to these files**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
|
|
209
226
|
|
|
210
227
|
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
228
|
|
|
@@ -215,13 +232,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
|
|
|
215
232
|
|
|
216
233
|
## Releases
|
|
217
234
|
|
|
218
|
-
Latest release: [v0.1.
|
|
235
|
+
Latest release: [v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
|
|
219
236
|
|
|
220
237
|
| Version | Date | Highlights |
|
|
221
238
|
|---|---|---|
|
|
239
|
+
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
|
|
222
240
|
| [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
241
|
| [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
242
|
|
|
226
243
|
See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
|
|
227
244
|
|
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 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host`
|
|
57
|
+
例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存为 JSON 文件,每条记忆都是稳定的 `{ "id", "content" }` 对象;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一组条目。插件会把精确的本地文件路径告诉 Pi,因此即使其他工具已路由到远端,Pi 仍能用普通 `read`、`edit`、`write` 工具对记忆条目增删查改。连接匹配的 endpoint 并启用远端工具路由后,有效条目会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
|
|
58
58
|
|
|
59
59
|
### 断线后可以自动恢复
|
|
60
60
|
|
|
@@ -107,7 +107,12 @@ pi install npm:pi-ssh-remote
|
|
|
107
107
|
```text
|
|
108
108
|
/remote cd /srv/project
|
|
109
109
|
/remote config note H100 训练机
|
|
110
|
-
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
然后直接告诉 Pi:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
添加一条服务器记忆:使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
|
|
111
116
|
```
|
|
112
117
|
|
|
113
118
|
查看当前状态:
|
|
@@ -183,7 +188,7 @@ pi install npm:pi-ssh-remote
|
|
|
183
188
|
/remote
|
|
184
189
|
```
|
|
185
190
|
|
|
186
|
-
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host`
|
|
191
|
+
备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 保存为独立 JSON 文件并跨端口共享。
|
|
187
192
|
|
|
188
193
|
### 4. 远端启动模型,本地开发界面
|
|
189
194
|
|
|
@@ -218,7 +223,24 @@ pi install npm:pi-ssh-remote
|
|
|
218
223
|
把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
|
|
219
224
|
```
|
|
220
225
|
|
|
221
|
-
|
|
226
|
+
```text
|
|
227
|
+
添加一条服务器记忆:部署必须使用 /opt/deploy/run.sh。
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
更新或删除前,可以先展示当前服务器的全部记忆和 ID:
|
|
231
|
+
|
|
232
|
+
```text
|
|
233
|
+
/remote memory
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
然后使用展示出的 ID:
|
|
237
|
+
|
|
238
|
+
```text
|
|
239
|
+
把 ID 为 deploy-command 的记忆更新为使用 /opt/deploy/v2/run.sh。
|
|
240
|
+
删除 ID 为 obsolete-proxy-rule 的服务器记忆。
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action 只负责定位和查看匹配的 JSON 文件,实际增删查改使用 Pi 自带的 `read`、`edit`、`write`。删除规则会明确注入给 Agent:只有用户明确要求“删除/移除/忘记”服务器记忆时,Pi 才能先读取文件、确认准确条目 ID,再只删除该对象;目标不明确时必须询问,修改请求不等于删除授权,删除全部条目也必须由用户明确提出。
|
|
222
244
|
|
|
223
245
|
## 命令说明
|
|
224
246
|
|
|
@@ -230,8 +252,7 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
230
252
|
| `/remote use USER@HOST:PORT` | 切换到指定服务器 |
|
|
231
253
|
| `/remote config note TEXT` | 给当前服务器添加或修改备注 |
|
|
232
254
|
| `/remote config note --clear` | 清除当前服务器备注 |
|
|
233
|
-
| `/remote
|
|
234
|
-
| `/remote config memory --clear` | 清除当前 `user@host` 的记忆 |
|
|
255
|
+
| `/remote memory` | 展示当前服务器的全部记忆条目、ID 和 JSON 文件路径 |
|
|
235
256
|
| `/remote config cwd PATH` | 设置默认远程工作目录 |
|
|
236
257
|
| `/remote cd PATH` | 切换当前远程目录并保存 |
|
|
237
258
|
| `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
|
|
@@ -264,16 +285,21 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
264
285
|
- 已保存的服务器;
|
|
265
286
|
- 当前选中的服务器;
|
|
266
287
|
- 服务器备注;
|
|
267
|
-
- 服务器特定记忆;
|
|
268
288
|
- 默认远程目录;
|
|
269
289
|
- 端口转发配置;
|
|
270
290
|
- 私钥文件路径;
|
|
271
291
|
- 命令预览设置;
|
|
272
292
|
- 模型输出预算。
|
|
273
293
|
|
|
274
|
-
|
|
294
|
+
服务器记忆单独保存在:
|
|
295
|
+
|
|
296
|
+
```text
|
|
297
|
+
~/.pi/agent/ssh-remote-memories/
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
文件结构为 `{ "server", "entries": [{ "id", "content" }] }`,旧版字符串记忆会自动迁移为 JSON 条目。此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。远端路由启用时,只有当前服务器对应的精确记忆文件路径会被 `read`、`edit`、`write` 特殊处理为本地文件。
|
|
275
301
|
|
|
276
|
-
私钥内容、密码和密钥 passphrase
|
|
302
|
+
私钥内容、密码和密钥 passphrase 都不会写入这些配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
|
|
277
303
|
|
|
278
304
|
## 安全与输出限制
|
|
279
305
|
|
|
@@ -294,13 +320,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。为防止误删
|
|
|
294
320
|
|
|
295
321
|
## 版本发布
|
|
296
322
|
|
|
297
|
-
最新版本:[v0.1.
|
|
323
|
+
最新版本:[v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
|
|
298
324
|
|
|
299
325
|
| 版本 | 日期 | 主要内容 |
|
|
300
326
|
|---|---|---|
|
|
327
|
+
| [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
|
|
301
328
|
| [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
|
|
302
329
|
| [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
330
|
|
|
305
331
|
完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
|
|
306
332
|
|
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")); }
|
|
@@ -782,6 +859,8 @@ async function askPassword(ctx: any): Promise<string | null> {
|
|
|
782
859
|
}
|
|
783
860
|
|
|
784
861
|
export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
862
|
+
migrateLegacyServerMemories();
|
|
863
|
+
|
|
785
864
|
const localCwd = process.cwd();
|
|
786
865
|
let remote: RemoteState | null = null;
|
|
787
866
|
let routeRemoteTools = false;
|
|
@@ -1270,16 +1349,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1270
1349
|
const localWrite = createWriteTool(localCwd);
|
|
1271
1350
|
const localEdit = createEditTool(localCwd);
|
|
1272
1351
|
const localBash = createBashTool(localCwd);
|
|
1352
|
+
const targetsLocalServerMemory = (path: unknown): boolean => {
|
|
1353
|
+
if (!remote || typeof path !== "string") return false;
|
|
1354
|
+
return resolve(path.replace(/^@/, "")) === serverMemoryFilePath(remote);
|
|
1355
|
+
};
|
|
1273
1356
|
pi.registerTool({
|
|
1274
1357
|
...localRead,
|
|
1275
1358
|
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
1359
|
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
|
|
1360
|
+
execute: (id, params, signal, update) => remote && routeRemoteTools && !targetsLocalServerMemory(params.path)
|
|
1278
1361
|
? executeRemoteRead(id, params, signal, update)
|
|
1279
1362
|
: localRead.execute(id, params, signal, update),
|
|
1280
1363
|
});
|
|
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) });
|
|
1364
|
+
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) });
|
|
1365
|
+
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
1366
|
pi.registerTool({
|
|
1284
1367
|
...localBash,
|
|
1285
1368
|
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 +1377,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1294
1377
|
pi.registerTool({
|
|
1295
1378
|
name: "remote",
|
|
1296
1379
|
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",
|
|
1380
|
+
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.",
|
|
1381
|
+
promptSnippet: "Control the configured remote SSH connection, endpoint note and memory location, working directory, and local port forwarding",
|
|
1299
1382
|
promptGuidelines: [
|
|
1300
1383
|
"Use remote when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
|
|
1301
1384
|
"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
|
|
1385
|
+
"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.",
|
|
1386
|
+
"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
1387
|
`Always set timeout for remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
|
|
1304
1388
|
"Keep remote exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
|
|
1305
1389
|
"Use remote with action disconnect after remote work when the user asks to return to the local environment.",
|
|
@@ -1308,7 +1392,6 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1308
1392
|
action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
|
|
1309
1393
|
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
1394
|
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
1395
|
cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
|
|
1313
1396
|
forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
|
|
1314
1397
|
remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
|
|
@@ -1357,47 +1440,29 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1357
1440
|
if (params.action === "note" || params.action === "memory") {
|
|
1358
1441
|
const command = params.command || lastCommand || activeSshCommand();
|
|
1359
1442
|
if (!command) throw new Error("No SSH endpoint configured; connect or select an endpoint first");
|
|
1360
|
-
const
|
|
1443
|
+
const parsed = parseSshCommand(command);
|
|
1444
|
+
const label = parsed.label;
|
|
1361
1445
|
if (params.action === "note") {
|
|
1362
1446
|
const note = params.note?.trim() || undefined;
|
|
1363
1447
|
saveEndpointConfig(command, { note });
|
|
1364
|
-
if (remote && cacheId(remote) === cacheId(
|
|
1448
|
+
if (remote && cacheId(remote) === cacheId(parsed) && currentCtx) status(currentCtx);
|
|
1365
1449
|
return {
|
|
1366
1450
|
content: [{ type: "text", text: note ? `SSH remote note updated (${label}): ${note}` : `SSH remote note cleared (${label})` }],
|
|
1367
1451
|
details: { endpoint: label, note },
|
|
1368
1452
|
};
|
|
1369
1453
|
}
|
|
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
|
-
};
|
|
1454
|
+
const path = ensureServerMemoryFile(parsed);
|
|
1455
|
+
let memory: ServerMemoryFile | undefined;
|
|
1456
|
+
try { memory = loadServerMemory(parsed); }
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
throw new Error(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`);
|
|
1378
1459
|
}
|
|
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);
|
|
1460
|
+
const text = memory?.entries.length
|
|
1461
|
+
? `SSH remote server memory (${serverMemoryId(parsed)}) is stored at ${path}:\n${JSON.stringify(memory.entries, null, 2)}`
|
|
1462
|
+
: `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
1463
|
return {
|
|
1399
|
-
content: [{ type: "text", text
|
|
1400
|
-
details: { endpoint: label, server,
|
|
1464
|
+
content: [{ type: "text", text }],
|
|
1465
|
+
details: { endpoint: label, server: serverMemoryId(parsed), path, entries: memory?.entries ?? [] },
|
|
1401
1466
|
};
|
|
1402
1467
|
}
|
|
1403
1468
|
if (params.action === "exec") {
|
|
@@ -1444,7 +1509,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1444
1509
|
});
|
|
1445
1510
|
|
|
1446
1511
|
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
|
|
1512
|
+
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
1513
|
handler: async (args, ctx) => {
|
|
1449
1514
|
const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
|
|
1450
1515
|
const action = input.toLowerCase();
|
|
@@ -1453,8 +1518,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1453
1518
|
const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
|
|
1454
1519
|
const active = key === config.activeEndpoint ? "*" : " ";
|
|
1455
1520
|
const command = endpoint.sshCommand || commandFromEndpointKey(key);
|
|
1456
|
-
|
|
1457
|
-
|
|
1521
|
+
let memory = "none";
|
|
1522
|
+
if (command) {
|
|
1523
|
+
const parsed = parseSshCommand(command);
|
|
1524
|
+
const path = serverMemoryFilePath(parsed);
|
|
1525
|
+
try {
|
|
1526
|
+
const entries = loadServerMemory(parsed)?.entries ?? [];
|
|
1527
|
+
memory = `${entries.length} JSON entr${entries.length === 1 ? "y" : "ies"} (${path})`;
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
memory = `invalid JSON (${path}: ${(error as Error).message})`;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
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
1533
|
});
|
|
1459
1534
|
const limits = configuredOutputLimits(config);
|
|
1460
1535
|
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 +1583,19 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
|
|
|
1508
1583
|
ctx.ui.notify(note ? `SSH remote note updated (${parseSshCommand(command).label}): ${note}` : `SSH remote note cleared (${parseSshCommand(command).label})`, "info");
|
|
1509
1584
|
return;
|
|
1510
1585
|
}
|
|
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();
|
|
1586
|
+
if (action === "memory") {
|
|
1587
|
+
const command = remote?.command || lastCommand || activeSshCommand();
|
|
1515
1588
|
if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
ctx.ui.notify(
|
|
1589
|
+
const parsed = parseSshCommand(command);
|
|
1590
|
+
let path: string;
|
|
1591
|
+
try { path = ensureServerMemoryFile(parsed); }
|
|
1592
|
+
catch (error) { ctx.ui.notify(`Could not initialize server-memory JSON: ${(error as Error).message}`, "error"); return; }
|
|
1593
|
+
try {
|
|
1594
|
+
const memory = loadServerMemory(parsed);
|
|
1595
|
+
ctx.ui.notify(`SSH remote server memory (${serverMemoryId(parsed)})\nFile: ${path}\n\n${formatServerMemoryEntries(memory)}`, "info");
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
ctx.ui.notify(`Invalid server-memory JSON at ${path}: ${(error as Error).message}`, "error");
|
|
1598
|
+
}
|
|
1520
1599
|
return;
|
|
1521
1600
|
}
|
|
1522
1601
|
if (/^config\s+display-lines\s+/i.test(input)) {
|