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 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.10...HEAD
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. Server memory is shared by `user@host`, so connecting as the same user to the same host through another port uses the same memory. 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 a matching endpoint is connected as the active remote workspace.
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, plus `user@host`-specific server memories, survive Pi restarts because they are stored in the local remote configuration. When Pi connects to an endpoint, the memory matching its user and host is added to the system 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 it from subsequent model requests.
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 config memory TEXT` | Persist context for the current `user@host`, shared across ports |
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, `user@host`-specific server memories, 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. 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. 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.
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.10](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.10)
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 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存,例如指定 Python 环境、共享任务保护规则或部署约定;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一份记忆。连接匹配的 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
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` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 共享,不受端口影响。
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 config memory TEXT` | 保存当前 `user@host` 的记忆,并在不同端口间共享 |
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 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。
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.10](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.10)
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 endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
416
- return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
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
- return systemPrompt.replace(
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 memory = endpointMemory(remote);
428
- if (!memory) return undefined;
429
- 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>`;
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
- client.once("error", reject);
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, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. 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.",
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 and no memory argument to inspect server memory. Non-empty memory text appends to the existing memory; clear it only by passing --clear explicitly.",
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 label = parseSshCommand(command).label;
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(parseSshCommand(command)) && currentCtx) status(currentCtx);
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 parsed = parseSshCommand(command);
1371
- const server = serverMemoryId(parsed);
1372
- if (params.memory === undefined) {
1373
- const memory = endpointMemory(parsed);
1374
- return {
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 addition = params.memory.trim();
1380
- if (!addition) throw new Error("memory must contain text; omit it to inspect the current memory or use --clear to clear it explicitly");
1381
- if (addition.toLowerCase() === "--clear") {
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: `SSH remote server memory appended (${server}). It applies to every port for this user and host.` }],
1400
- details: { endpoint: label, server, memory, addedMemory: addition, changed: true },
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 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",
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
- const memory = command ? endpointMemory(parseSshCommand(command), config) : undefined;
1457
- return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
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 (/^config\s+memory(?:\s+|$)/i.test(input)) {
1512
- const value = input.replace(/^config\s+memory\s*/i, "").trim();
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 memory = value.toLowerCase() === "--clear" ? undefined : value;
1517
- const server = serverMemoryId(parseSshCommand(command));
1518
- saveServerMemory(command, memory);
1519
- ctx.ui.notify(memory ? `SSH remote server memory updated (${server}); it applies to every port` : `SSH remote server memory cleared (${server})`, "info");
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",