pi-ssh-remote 0.1.7 → 0.1.8

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,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
18
18
 
19
19
  - None.
20
20
 
21
+ ## [0.1.8] - 2026-08-10
22
+
23
+ ### Added
24
+
25
+ - Added explicit private-key authentication through `ssh -i KEY`, including passphrase-protected keys and in-memory passphrase caching for reconnects.
26
+
27
+ ### Changed
28
+
29
+ - Renamed the agent-facing `ssh_remote_control` tool to the shorter `remote` name, matching the `/remote` command.
30
+ - Server memory is now keyed by `user@host` and shared across SSH ports; existing endpoint memories are migrated automatically.
31
+ - `/remote forget` now clears cached private-key passphrases as well as passwords.
32
+
33
+ ### Fixed
34
+
35
+ - None.
36
+
21
37
  ## [0.1.7] - 2026-08-05
22
38
 
23
39
  ### Added
@@ -145,7 +161,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
145
161
 
146
162
  - None.
147
163
 
148
- [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...HEAD
164
+ [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...HEAD
165
+ [0.1.8]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.7...v0.1.8
149
166
  [0.1.7]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...v0.1.7
150
167
  [0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
151
168
  [0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -27,11 +27,11 @@ Once connected, Pi's file tools, shell tool, and `!` user shell commands are tra
27
27
 
28
28
  ### Agent control plane, human control plane
29
29
 
30
- The extension exposes `ssh_remote_control` for the agent and `/remote` for the user. You can ask Pi to connect, inspect status, change directory, add a server note, execute a command, create a tunnel, or return to local work in natural language—and still take direct control whenever you want.
30
+ The extension exposes `remote` for the agent and `/remote` for the user. You can ask Pi to connect, inspect status, change directory, add a server note, execute a command, create a tunnel, or return to local work in natural language—and still take direct control whenever you want.
31
31
 
32
32
  ### Stateful endpoints instead of disposable SSH commands
33
33
 
34
- Each `user@host:port` endpoint remembers its remote working directory, note, server-specific memory, and port-forward configuration locally. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible. Server memory is automatically injected into the model context whenever that endpoint is connected as the active remote workspace.
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.
35
35
 
36
36
  ### Resilient and bounded by default
37
37
 
@@ -82,6 +82,16 @@ Return to local tools with:
82
82
  /remote off
83
83
  ```
84
84
 
85
+ ### Private key authentication
86
+
87
+ Pass an explicit local private key with `-i`:
88
+
89
+ ```text
90
+ /remote ssh -i ~/.ssh/id_ed25519 root@gpu-box.example.com -p 2202
91
+ ```
92
+
93
+ Identity paths must be absolute or start with `~/`. Unencrypted and passphrase-protected private keys are supported. Pi prompts for an encrypted key's passphrase and caches it only in the current process for reconnects; `/remote forget` clears it. When `-i` is present, that key is used exclusively instead of silently falling back to SSH agent or password authentication. Commands without `-i` keep the existing SSH agent and password flow unchanged.
94
+
85
95
  ## Examples
86
96
 
87
97
  ### 1. Let the agent investigate and repair a remote failure
@@ -121,7 +131,7 @@ The default foreground timeout prevents an accidental long-running command from
121
131
  /remote
122
132
  ```
123
133
 
124
- Endpoint notes, working directories, and server-specific memories survive Pi restarts because they are stored in the local remote configuration. When Pi connects to an endpoint, its memory is added to the system context on every agent run while remote tool routing remains active. Disconnecting or switching to tunnel-only mode removes it from subsequent model requests.
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.
125
135
 
126
136
  ### 4. Expose a remote service while editing locally
127
137
 
@@ -154,20 +164,20 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
154
164
  tools on the local repository.
155
165
  ```
156
166
 
157
- These requests are handled through the agent-facing `ssh_remote_control` tool; slash commands are optional.
167
+ These requests are handled through the agent-facing `remote` tool; slash commands are optional.
158
168
 
159
169
  ## Command reference
160
170
 
161
171
  | Command | Purpose |
162
172
  |---|---|
163
- | `/remote ssh USER@HOST -p PORT` | Save, select, and connect to an endpoint |
173
+ | `/remote ssh USER@HOST -p PORT [-i KEY]` | Save, select, and connect using agent/password or an explicit private key |
164
174
  | `/remote` | Connect to the selected endpoint or prompt for one |
165
175
  | `/remote config` | List saved endpoints and settings |
166
176
  | `/remote use USER@HOST:PORT` | Select a saved endpoint |
167
177
  | `/remote config note TEXT` | Persist a note for the selected endpoint |
168
178
  | `/remote config note --clear` | Clear its note |
169
- | `/remote config memory TEXT` | Persist context that is injected while working on this endpoint |
170
- | `/remote config memory --clear` | Clear its server-specific memory |
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 |
171
181
  | `/remote config cwd PATH` | Persist its default remote working directory |
172
182
  | `/remote cd PATH` | Change the connected remote cwd and persist it |
173
183
  | `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
@@ -185,7 +195,7 @@ These requests are handled through the agent-facing `ssh_remote_control` tool; s
185
195
  | `/remote status` | Show the active workspace |
186
196
  | `/remote reload` | Reconnect the active workspace |
187
197
  | `/remote off` | Disconnect and return tools to local execution |
188
- | `/remote forget` | Disconnect and clear the in-memory password |
198
+ | `/remote forget` | Disconnect and clear cached passwords and key passphrases |
189
199
 
190
200
  ## Persistence, output, and security
191
201
 
@@ -195,23 +205,23 @@ Endpoint configuration is stored locally in:
195
205
  ~/.pi/agent/ssh-remote-config.json
196
206
  ```
197
207
 
198
- Saved global values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, 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 that endpoint is the active remote workspace; it is not read from the remote server. Passwords are **never written to this file**: SSH agent authentication is preferred, and prompted passwords remain only in process memory.
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.
199
209
 
200
210
  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.
201
211
 
202
212
  ## Current SSH scope
203
213
 
204
- The extension currently supports direct SSH commands with `-p` and `-l`. It does not yet consume `~/.ssh/config`, `IdentityFile`, or ProxyJump settings.
214
+ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`. It does not yet consume `~/.ssh/config` or ProxyJump settings; use `-i` explicitly instead of relying on an `IdentityFile` entry.
205
215
 
206
216
  ## Releases
207
217
 
208
- Latest release: [v0.1.7](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.7)
218
+ Latest release: [v0.1.8](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.8)
209
219
 
210
220
  | Version | Date | Highlights |
211
221
  |---|---|---|
222
+ | [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 |
212
223
  | [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | Session-aware SSH workspace restoration for new, forked, and resumed sessions |
213
224
  | [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | Bounded remote reads, streamed command output, and per-turn output budgets |
214
- | [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | Endpoint-specific server memory and automatic context injection |
215
225
 
216
226
  See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
217
227
 
package/README.zh-CN.md CHANGED
@@ -54,7 +54,7 @@ SSH remote H100 训练机 (root@gpu-box.example.com:2202):/srv/project
54
54
  - 服务器特定记忆;
55
55
  - 端口转发配置。
56
56
 
57
- 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。还可以为每个 `user@host:port` 保存独立记忆,例如指定 Python 环境、共享任务保护规则或部署约定。连接该 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
57
+ 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存,例如指定 Python 环境、共享任务保护规则或部署约定;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一份记忆。连接匹配的 endpoint 并启用远端工具路由后,这段记忆会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
58
58
 
59
59
  ### 断线后可以自动恢复
60
60
 
@@ -124,6 +124,16 @@ pi install npm:pi-ssh-remote
124
124
  /remote off
125
125
  ```
126
126
 
127
+ ### 使用私钥文件登录
128
+
129
+ 通过 `-i` 指定本地私钥:
130
+
131
+ ```text
132
+ /remote ssh -i ~/.ssh/id_ed25519 root@gpu-box.example.com -p 2202
133
+ ```
134
+
135
+ 密钥路径必须是绝对路径或以 `~/` 开头。插件支持未加密私钥和带 passphrase 的私钥;对于加密私钥,Pi 会以遮罩方式询问 passphrase,并仅在当前进程内缓存以便自动重连,`/remote forget` 会清除它。指定 `-i` 后只使用该私钥,不会静默回退到 SSH Agent 或密码;不带 `-i` 的旧命令仍保持原有的 SSH Agent 和密码登录流程。
136
+
127
137
  ## 常见用法
128
138
 
129
139
  ### 1. 让 Pi 直接排查远程服务故障
@@ -173,7 +183,7 @@ pi install npm:pi-ssh-remote
173
183
  /remote
174
184
  ```
175
185
 
176
- 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
186
+ 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 共享,不受端口影响。
177
187
 
178
188
  ### 4. 远端启动模型,本地开发界面
179
189
 
@@ -208,20 +218,20 @@ pi install npm:pi-ssh-remote
208
218
  把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
209
219
  ```
210
220
 
211
- Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
221
+ Pi 会通过插件提供的 `remote` 工具完成这些操作。
212
222
 
213
223
  ## 命令说明
214
224
 
215
225
  | 命令 | 作用 |
216
226
  |---|---|
217
- | `/remote ssh USER@HOST -p PORT` | 保存并连接服务器 |
227
+ | `/remote ssh USER@HOST -p PORT [-i KEY]` | 保存并使用 Agent/密码或指定私钥连接服务器 |
218
228
  | `/remote` | 连接当前选中的服务器,或提示输入 SSH 地址 |
219
229
  | `/remote config` | 查看已保存的服务器和配置 |
220
230
  | `/remote use USER@HOST:PORT` | 切换到指定服务器 |
221
231
  | `/remote config note TEXT` | 给当前服务器添加或修改备注 |
222
232
  | `/remote config note --clear` | 清除当前服务器备注 |
223
- | `/remote config memory TEXT` | 保存连接该服务器时自动注入上下文的记忆 |
224
- | `/remote config memory --clear` | 清除当前服务器的特定记忆 |
233
+ | `/remote config memory TEXT` | 保存当前 `user@host` 的记忆,并在不同端口间共享 |
234
+ | `/remote config memory --clear` | 清除当前 `user@host` 的记忆 |
225
235
  | `/remote config cwd PATH` | 设置默认远程工作目录 |
226
236
  | `/remote cd PATH` | 切换当前远程目录并保存 |
227
237
  | `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
@@ -239,7 +249,7 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
239
249
  | `/remote status` | 查看当前连接和工作目录 |
240
250
  | `/remote reload` | 重新连接当前服务器 |
241
251
  | `/remote off` | 断开连接并返回本地 |
242
- | `/remote forget` | 断开连接并清除内存中的密码 |
252
+ | `/remote forget` | 断开连接并清除内存中的密码和密钥 passphrase |
243
253
 
244
254
  ## 配置保存在哪里
245
255
 
@@ -257,12 +267,13 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
257
267
  - 服务器特定记忆;
258
268
  - 默认远程目录;
259
269
  - 端口转发配置;
270
+ - 私钥文件路径;
260
271
  - 命令预览设置;
261
272
  - 模型输出预算。
262
273
 
263
- 此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当对应 endpoint 作为远端工作区启用时才会加入每次模型请求。
274
+ 此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。
264
275
 
265
- 密码不会写入配置文件。插件会优先使用 SSH agent;如果需要手动输入密码,密码只会缓存在当前 Pi 进程的内存中。
276
+ 私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
266
277
 
267
278
  ## 安全与输出限制
268
279
 
@@ -279,17 +290,17 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
279
290
 
280
291
  ## 当前限制
281
292
 
282
- 目前只支持 SSH 直连,以及 `-p`、`-l` 参数。暂不读取 `~/.ssh/config`,也不支持 `IdentityFile` ProxyJump
293
+ 目前只支持 SSH 直连,以及 `-p`、`-l`、`-i` 参数。暂不读取 `~/.ssh/config` ProxyJump;如需指定私钥,请显式使用 `-i`,不要依赖 SSH config 中的 `IdentityFile`。
283
294
 
284
295
  ## 版本发布
285
296
 
286
- 最新版本:[v0.1.7](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.7)
297
+ 最新版本:[v0.1.8](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.8)
287
298
 
288
299
  | 版本 | 日期 | 主要内容 |
289
300
  |---|---|---|
301
+ | [0.1.8](CHANGELOG.md#018---2026-08-10) | 2026-08-10 | 显式私钥登录、跨端口服务器记忆与更短的 `remote` 工具名 |
290
302
  | [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | 新建、分叉和恢复 session 时自动恢复对应 SSH 工作区 |
291
303
  | [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | 有界远端读取、流式命令输出与每轮输出预算 |
292
- | [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | endpoint 级服务器记忆与自动上下文注入 |
293
304
 
294
305
  完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
295
306
 
package/index.ts CHANGED
@@ -6,9 +6,9 @@
6
6
  * credentials, remote working directories, reconnection, and TCP forwarding.
7
7
  */
8
8
 
9
- import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
10
- import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs";
11
- import { dirname, join, posix, relative, sep } from "node:path";
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";
12
12
  import { createServer, type Server, type Socket } from "node:net";
13
13
  import { tmpdir } from "node:os";
14
14
  import { Type } from "typebox";
@@ -33,21 +33,25 @@ import {
33
33
  } from "@earendil-works/pi-coding-agent";
34
34
  import { CURSOR_MARKER, Key, Text, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
35
35
 
36
+ const { Client, utils: ssh2Utils } = ssh2;
37
+
36
38
  interface ParsedSsh {
37
39
  host: string;
38
40
  port: number;
39
41
  username: string;
42
+ identityFile?: string;
40
43
  label: string;
41
44
  command: string;
42
45
  }
43
46
 
44
47
  interface RemoteState extends ParsedSsh {
45
- client: Client;
48
+ client: SshClient;
46
49
  cwd: string;
47
50
  }
48
51
 
49
52
  interface CredentialCache {
50
53
  passwords: Map<string, string>;
54
+ keyPassphrases: Map<string, string>;
51
55
  resume?: { command: string; cwd: string; routeRemoteTools: boolean; forwards?: string[] };
52
56
  }
53
57
 
@@ -56,12 +60,14 @@ interface RemoteEndpointConfig {
56
60
  remoteCwd?: string;
57
61
  forwards?: string[];
58
62
  note?: string;
63
+ /** Legacy field migrated into serverMemories on the next config write. */
59
64
  memory?: string;
60
65
  }
61
66
 
62
67
  interface RemoteConfig {
63
68
  activeEndpoint?: string;
64
69
  endpoints?: Record<string, RemoteEndpointConfig>;
70
+ serverMemories?: Record<string, string>;
65
71
  displayLines?: number;
66
72
  readMaxLines?: number;
67
73
  readMaxBytes?: number;
@@ -104,10 +110,12 @@ const MIN_MODEL_OUTPUT_BYTES = 1024;
104
110
  const OUTPUT_FOOTER_RESERVE_BYTES = 512;
105
111
  const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
106
112
  const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
113
+ const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
107
114
  const SESSION_STATE_ENTRY_TYPE = "pi-ssh-remote-state";
108
115
  const CACHE_KEY = "__piHpcCredentialCacheV1";
109
116
  const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
110
- const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
117
+ const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>(), keyPassphrases: new Map<string, string>() };
118
+ credentialCache.keyPassphrases ??= new Map<string, string>();
111
119
 
112
120
  function shellWords(input: string): string[] {
113
121
  const words: string[] = [];
@@ -132,28 +140,47 @@ function parseSshCommand(command: string): ParsedSsh {
132
140
  if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
133
141
  let port = 22;
134
142
  let username = process.env.USER || "root";
143
+ let identityFile: string | undefined;
135
144
  let target: string | undefined;
136
145
  for (let i = 1; i < args.length; i++) {
137
146
  const arg = args[i]!;
138
147
  if (arg === "-p") { port = Number(args[++i]); continue; }
139
148
  if (arg.startsWith("-p") && arg.length > 2) { port = Number(arg.slice(2)); continue; }
140
149
  if (arg === "-l") { username = args[++i] || username; continue; }
141
- if (arg.startsWith("-")) throw new Error(`Unsupported SSH option ${arg}; only -p and -l are currently supported`);
150
+ if (arg === "-i") {
151
+ if (identityFile !== undefined) throw new Error("Only one SSH identity file may be specified");
152
+ identityFile = args[++i];
153
+ if (!identityFile) throw new Error("SSH option -i requires a private key path");
154
+ continue;
155
+ }
156
+ if (arg.startsWith("-i") && arg.length > 2) {
157
+ if (identityFile !== undefined) throw new Error("Only one SSH identity file may be specified");
158
+ identityFile = arg.slice(2);
159
+ continue;
160
+ }
161
+ if (arg.startsWith("-")) throw new Error(`Unsupported SSH option ${arg}; only -p, -l, and -i are currently supported`);
142
162
  if (!target) target = arg;
143
163
  else throw new Error("Unexpected extra argument in SSH command");
144
164
  }
145
165
  if (!target || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid SSH host or port");
166
+ if (identityFile && identityFile !== "~" && !identityFile.startsWith("~/") && !isAbsolute(identityFile)) {
167
+ throw new Error("SSH identity file must use an absolute path or ~/...");
168
+ }
146
169
  const at = target.lastIndexOf("@");
147
170
  const host = at >= 0 ? target.slice(at + 1) : target;
148
171
  if (at >= 0) username = target.slice(0, at);
149
172
  if (!host || !username) throw new Error("Invalid SSH username or host");
150
- return { host, port, username, label: `${username}@${host}:${port}`, command };
173
+ return { host, port, username, ...(identityFile ? { identityFile } : {}), label: `${username}@${host}:${port}`, command };
151
174
  }
152
175
 
153
176
  function cacheId(config: ParsedSsh): string {
154
177
  return `${config.username}@${config.host}:${config.port}`;
155
178
  }
156
179
 
180
+ function serverMemoryId(config: ParsedSsh): string {
181
+ return `${config.username}@${config.host}`;
182
+ }
183
+
157
184
  function getCachedPassword(config: ParsedSsh): string | undefined {
158
185
  return credentialCache.passwords.get(cacheId(config));
159
186
  }
@@ -166,6 +193,60 @@ function deleteCachedPassword(config: ParsedSsh): void {
166
193
  credentialCache.passwords.delete(cacheId(config));
167
194
  }
168
195
 
196
+ function resolveIdentityPath(config: ParsedSsh): string {
197
+ if (!config.identityFile) throw new Error("No SSH identity file is configured");
198
+ if (config.identityFile === "~" || config.identityFile.startsWith("~/")) {
199
+ const home = process.env.HOME;
200
+ if (!home) throw new Error("Cannot expand SSH identity path because HOME is not set");
201
+ return config.identityFile === "~" ? home : join(home, config.identityFile.slice(2));
202
+ }
203
+ return config.identityFile;
204
+ }
205
+
206
+ function keyPassphraseId(config: ParsedSsh): string {
207
+ return `${cacheId(config)}|${resolveIdentityPath(config)}`;
208
+ }
209
+
210
+ function getCachedKeyPassphrase(config: ParsedSsh): string | undefined {
211
+ return credentialCache.keyPassphrases.get(keyPassphraseId(config));
212
+ }
213
+
214
+ function setCachedKeyPassphrase(config: ParsedSsh, passphrase: string): void {
215
+ credentialCache.keyPassphrases.set(keyPassphraseId(config), passphrase);
216
+ }
217
+
218
+ function deleteCachedKeyPassphrase(config: ParsedSsh): void {
219
+ if (config.identityFile) credentialCache.keyPassphrases.delete(keyPassphraseId(config));
220
+ }
221
+
222
+ function readPrivateKey(config: ParsedSsh): Buffer {
223
+ const path = resolveIdentityPath(config);
224
+ let stat;
225
+ try { stat = statSync(path); }
226
+ catch (error) { throw new Error(`Cannot access SSH private key ${path}: ${(error as Error).message}`); }
227
+ if (!stat.isFile()) throw new Error(`SSH private key is not a regular file: ${path}`);
228
+ if (stat.size > MAX_PRIVATE_KEY_BYTES) throw new Error(`SSH private key exceeds the ${MAX_PRIVATE_KEY_BYTES}-byte limit: ${path}`);
229
+ try { return readFileSync(path); }
230
+ catch (error) { throw new Error(`Cannot read SSH private key ${path}: ${(error as Error).message}`); }
231
+ }
232
+
233
+ function parsePrivateKey(keyData: Buffer, passphrase?: string): any | Error {
234
+ let parsed: any;
235
+ try { parsed = ssh2Utils.parseKey(keyData, passphrase); }
236
+ catch (error) { return error as Error; }
237
+ if (parsed instanceof Error) return parsed;
238
+ const keys = Array.isArray(parsed) ? parsed : [parsed];
239
+ const privateKeys = keys.filter((key) => key?.isPrivateKey?.());
240
+ if (privateKeys.length !== 1) {
241
+ return new Error(privateKeys.length ? "SSH identity files containing multiple private keys are not supported" : "SSH identity file does not contain a private key");
242
+ }
243
+ return privateKeys[0];
244
+ }
245
+
246
+ function isPassphraseError(error: Error): boolean {
247
+ return /passphrase|encrypted private/i.test(error.message);
248
+ }
249
+
169
250
  function parseForwardSpec(value: string): ForwardSpec {
170
251
  const match = value.match(/^(\d+):([^:]+):(\d+)$/);
171
252
  if (!match) throw new Error(`Invalid port-forward specification: ${value}; expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT`);
@@ -241,11 +322,25 @@ function commandFromEndpointKey(key: string): string | undefined {
241
322
 
242
323
  function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
243
324
  const endpoints = { ...(config.endpoints ?? {}) };
244
- for (const [key, endpoint] of Object.entries(endpoints)) {
325
+ const serverMemories = Object.fromEntries(
326
+ Object.entries(config.serverMemories ?? {})
327
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && Boolean(entry[1].trim()))
328
+ .map(([key, memory]) => [key, memory.trim()]),
329
+ );
330
+ const endpointEntries = Object.entries(endpoints).sort(([left], [right]) =>
331
+ left === config.activeEndpoint ? -1 : right === config.activeEndpoint ? 1 : 0,
332
+ );
333
+ for (const [key, endpoint] of endpointEntries) {
334
+ const command = endpoint.sshCommand || commandFromEndpointKey(key);
335
+ const { memory: legacyMemory, ...endpointWithoutMemory } = endpoint;
245
336
  endpoints[key] = {
246
- ...endpoint,
247
- ...(endpoint.sshCommand ? {} : { sshCommand: commandFromEndpointKey(key) }),
337
+ ...endpointWithoutMemory,
338
+ ...(command ? { sshCommand: command } : {}),
248
339
  };
340
+ if (legacyMemory?.trim() && command) {
341
+ try { serverMemories[serverMemoryId(parseSshCommand(command))] ??= legacyMemory.trim(); }
342
+ catch {}
343
+ }
249
344
  }
250
345
 
251
346
  let activeEndpoint = config.activeEndpoint;
@@ -279,6 +374,7 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
279
374
  return {
280
375
  ...(activeEndpoint ? { activeEndpoint } : {}),
281
376
  ...(Object.keys(endpoints).length ? { endpoints } : {}),
377
+ ...(Object.keys(serverMemories).length ? { serverMemories } : {}),
282
378
  ...(displayLines !== undefined ? { displayLines } : {}),
283
379
  ...(readMaxLines !== undefined ? { readMaxLines } : {}),
284
380
  ...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
@@ -317,13 +413,13 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
317
413
  }
318
414
 
319
415
  function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
320
- return endpointConfig(config, endpoint.command).memory?.trim() || undefined;
416
+ return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
321
417
  }
322
418
 
323
419
  function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
324
420
  return systemPrompt.replace(
325
421
  `Current working directory: ${localCwd}`,
326
- `Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use ssh_remote_control with action disconnect to return to the local environment when requested.`,
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.`,
327
423
  );
328
424
  }
329
425
 
@@ -346,6 +442,24 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
346
442
  });
347
443
  }
348
444
 
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
+
349
463
  function loadKnownHosts(): Record<string, string> {
350
464
  try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
351
465
  catch { return {}; }
@@ -518,15 +632,16 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
518
632
  });
519
633
  }
520
634
 
521
- function connect(config: ParsedSsh, password: string | undefined, fingerprint: string): Promise<Client> {
635
+ type SshAuthentication = Partial<Pick<ConnectConfig, "password" | "privateKey" | "passphrase" | "agent">>;
636
+
637
+ function connect(config: ParsedSsh, authentication: SshAuthentication, fingerprint: string): Promise<SshClient> {
522
638
  return new Promise((resolve, reject) => {
523
639
  const client = new Client();
524
640
  const options: ConnectConfig = {
525
641
  host: config.host,
526
642
  port: config.port,
527
643
  username: config.username,
528
- ...(password ? { password } : {}),
529
- ...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
644
+ ...authentication,
530
645
  readyTimeout: 12000,
531
646
  keepaliveInterval: 15000,
532
647
  keepaliveCountMax: 3,
@@ -540,7 +655,7 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
540
655
  }
541
656
 
542
657
  function execRemote(
543
- client: Client,
658
+ client: SshClient,
544
659
  command: string,
545
660
  allowFailure = false,
546
661
  timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
@@ -574,7 +689,7 @@ function execRemote(
574
689
  }
575
690
 
576
691
  function execRemoteLimited(
577
- client: Client,
692
+ client: SshClient,
578
693
  command: string,
579
694
  timeoutSeconds: number,
580
695
  maxLines: number,
@@ -620,11 +735,11 @@ function execRemoteLimited(
620
735
  });
621
736
  }
622
737
 
623
- function getSftp(client: Client): Promise<SFTPWrapper> {
738
+ function getSftp(client: SshClient): Promise<SFTPWrapper> {
624
739
  return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
625
740
  }
626
741
 
627
- async function withSftp<T>(client: Client, operation: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
742
+ async function withSftp<T>(client: SshClient, operation: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
628
743
  const sftp = await getSftp(client);
629
744
  try { return await operation(sftp); }
630
745
  finally { sftp.end(); }
@@ -635,10 +750,10 @@ function isReconnectable(error: unknown): boolean {
635
750
  return /channel open failure|open failed|not connected|no response|econnreset|econnrefused|etimedout|ehostunreach|epipe|connection (?:lost|closed)|socket.*closed|client is not connected/i.test(message);
636
751
  }
637
752
 
638
- class PasswordInput implements Component, Focusable {
753
+ class SecretInput implements Component, Focusable {
639
754
  focused = false;
640
755
  private value = "";
641
- constructor(private done: (value: string | null) => void, private renderNow: () => void) {}
756
+ constructor(private label: string, private done: (value: string | null) => void, private renderNow: () => void) {}
642
757
  handleInput(data: string): void {
643
758
  if (matchesKey(data, Key.enter)) return this.done(this.value);
644
759
  if (matchesKey(data, Key.escape)) return this.done(null);
@@ -651,15 +766,19 @@ class PasswordInput implements Component, Focusable {
651
766
  this.renderNow();
652
767
  }
653
768
  render(width: number): string[] {
654
- return [truncateToWidth(`SSH password: ${"•".repeat([...this.value].length)}${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[27m`, width, "")];
769
+ return [truncateToWidth(`${this.label}: ${"•".repeat([...this.value].length)}${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[27m`, width, "")];
655
770
  }
656
771
  invalidate(): void {}
657
772
  }
658
773
 
659
- async function askPassword(ctx: any): Promise<string | null> {
660
- if (ctx.mode !== "tui") return (await ctx.ui.input("SSH password:", "password")) ?? null;
774
+ async function askSecret(ctx: any, label: string, placeholder: string): Promise<string | null> {
775
+ if (ctx.mode !== "tui") return (await ctx.ui.input(`${label}:`, placeholder)) ?? null;
661
776
  return ctx.ui.custom<string | null>((tui: any, _theme: any, _keys: any, done: (value: string | null) => void) =>
662
- new PasswordInput(done, () => tui.requestRender()));
777
+ new SecretInput(label, done, () => tui.requestRender()));
778
+ }
779
+
780
+ async function askPassword(ctx: any): Promise<string | null> {
781
+ return askSecret(ctx, "SSH password", "password");
663
782
  }
664
783
 
665
784
  export default function sshRemoteExtension(pi: ExtensionAPI) {
@@ -682,6 +801,33 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
682
801
  const configuredForwards = (command: string): string[] =>
683
802
  endpointConfig(loadRemoteConfig(), command).forwards ?? [];
684
803
 
804
+ const standardAuthentication = (password?: string): SshAuthentication => ({
805
+ ...(password ? { password } : {}),
806
+ ...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
807
+ });
808
+
809
+ const privateKeyAuthentication = async (parsed: ParsedSsh, ctx?: any): Promise<SshAuthentication> => {
810
+ const keyData = readPrivateKey(parsed);
811
+ let passphrase = getCachedKeyPassphrase(parsed);
812
+ let privateKey = parsePrivateKey(keyData, passphrase);
813
+ if (privateKey instanceof Error && isPassphraseError(privateKey)) {
814
+ if (passphrase) deleteCachedKeyPassphrase(parsed);
815
+ if (!ctx) throw new Error(`SSH private key ${resolveIdentityPath(parsed)} requires its passphrase again; reconnect interactively`);
816
+ passphrase = await askSecret(ctx, `Passphrase for ${parsed.identityFile}`, "private key passphrase") ?? undefined;
817
+ if (!passphrase) throw new Error("No SSH private key passphrase was provided");
818
+ privateKey = parsePrivateKey(keyData, passphrase);
819
+ if (privateKey instanceof Error) {
820
+ deleteCachedKeyPassphrase(parsed);
821
+ throw new Error(`Could not unlock SSH private key ${resolveIdentityPath(parsed)}: ${privateKey.message}`);
822
+ }
823
+ setCachedKeyPassphrase(parsed, passphrase);
824
+ }
825
+ if (privateKey instanceof Error) {
826
+ throw new Error(`Invalid SSH private key ${resolveIdentityPath(parsed)}: ${privateKey.message}`);
827
+ }
828
+ return { privateKey: keyData, ...(passphrase ? { passphrase } : {}) };
829
+ };
830
+
685
831
  const mapPath = (path: string): string => {
686
832
  if (!remote) return path;
687
833
  if (path === localCwd) return remote.cwd;
@@ -802,11 +948,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
802
948
  });
803
949
  };
804
950
 
805
- const establish = async (parsed: ParsedSsh, password: string | undefined, cwd: string): Promise<RemoteState> => {
951
+ const establish = async (parsed: ParsedSsh, authentication: SshAuthentication, cwd: string): Promise<RemoteState> => {
806
952
  const key = `${parsed.host}:${parsed.port}`;
807
953
  const fingerprint = loadKnownHosts()[key];
808
954
  if (!fingerprint) throw new Error(`Host ${key} is not trusted; connect interactively with /remote first`);
809
- const client = await connect(parsed, password, fingerprint);
955
+ const client = await connect(parsed, authentication, fingerprint);
810
956
  try {
811
957
  const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
812
958
  const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
@@ -827,8 +973,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
827
973
  const parsed = parseSshCommand(source.command);
828
974
  const password = getCachedPassword(parsed);
829
975
  reconnectPromise = (async () => {
976
+ const authentication = parsed.identityFile
977
+ ? await privateKeyAuthentication(parsed)
978
+ : standardAuthentication(password);
830
979
  const oldClient = remote?.client;
831
- const next = await establish(parsed, password, source.cwd);
980
+ const next = await establish(parsed, authentication, source.cwd);
832
981
  remote = next;
833
982
  routeRemoteTools = resumeRouting;
834
983
  credentialCache.resume = { command: parsed.command, cwd: next.cwd, routeRemoteTools, forwards: credentialCache.resume?.forwards };
@@ -842,7 +991,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
842
991
  return reconnectPromise;
843
992
  }
844
993
 
845
- const withReconnect = async <T>(operation: (client: Client) => Promise<T>): Promise<T> => {
994
+ const withReconnect = async <T>(operation: (client: SshClient) => Promise<T>): Promise<T> => {
846
995
  if (!remote) throw new Error("SSH remote is not connected");
847
996
  try { return await operation(remote.client); }
848
997
  catch (error) {
@@ -908,24 +1057,28 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
908
1057
  let password = getCachedPassword(parsed);
909
1058
  ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
910
1059
  try {
1060
+ let authentication = parsed.identityFile
1061
+ ? await privateKeyAuthentication(parsed, ctx)
1062
+ : standardAuthentication(password);
911
1063
  let next: RemoteState;
912
1064
  try {
913
- next = await establish(parsed, password, cwd ?? configuredCwd(command));
1065
+ next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
914
1066
  } catch (error) {
915
- if (!/authentication methods failed|authentication failure/i.test((error as Error).message)) throw error;
1067
+ if (parsed.identityFile || !/authentication methods failed|authentication failure/i.test((error as Error).message)) throw error;
916
1068
  password = await askPassword(ctx) ?? undefined;
917
1069
  if (!password) {
918
1070
  lastConnectionError = "No SSH password was provided and SSH agent authentication failed";
919
1071
  status(ctx);
920
1072
  return null;
921
1073
  }
922
- next = await establish(parsed, password, cwd ?? configuredCwd(command));
1074
+ authentication = standardAuthentication(password);
1075
+ next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
923
1076
  }
924
1077
  const previous = remote?.client;
925
1078
  remote = next;
926
1079
  routeRemoteTools = true;
927
1080
  previous?.end();
928
- if (password) setCachedPassword(parsed, password);
1081
+ if (!parsed.identityFile && password) setCachedPassword(parsed, password);
929
1082
  credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [] };
930
1083
  lastCommand = command;
931
1084
  lastConnectionError = undefined;
@@ -935,7 +1088,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
935
1088
  ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
936
1089
  return next;
937
1090
  } catch (error) {
938
- deleteCachedPassword(parsed);
1091
+ if (!parsed.identityFile) deleteCachedPassword(parsed);
939
1092
  remote = null;
940
1093
  lastConnectionError = (error as Error).message;
941
1094
  status(ctx);
@@ -948,7 +1101,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
948
1101
  if (remote) return remote;
949
1102
  if (credentialCache.resume) return reconnectRemote();
950
1103
  const command = lastCommand || activeSshCommand();
951
- if (!command) throw new Error("No SSH endpoint configured; use /remote ssh USER@HOST -p PORT");
1104
+ if (!command) throw new Error("No SSH endpoint configured; use /remote ssh USER@HOST -p PORT [-i KEY]");
952
1105
  const state = await connectInteractive(command, ctx, configuredCwd(command));
953
1106
  if (!state) throw new Error("SSH remote connection was cancelled or failed");
954
1107
  return state;
@@ -1001,26 +1154,28 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1001
1154
  status(ctx);
1002
1155
  };
1003
1156
 
1004
- const disconnect = (ctx: any, forgetPassword = false) => {
1157
+ const disconnect = (ctx: any, forgetCredentials = false) => {
1005
1158
  const previous = remote;
1006
1159
  remote = null;
1007
1160
  routeRemoteTools = false;
1008
1161
  reconnectPromise = null;
1009
1162
  credentialCache.resume = undefined;
1010
1163
  void stopForwards();
1011
- if (forgetPassword) {
1012
- if (previous) deleteCachedPassword(previous);
1013
- else {
1014
- const configured = activeSshCommand();
1015
- if (configured) {
1016
- try { deleteCachedPassword(parseSshCommand(configured)); } catch {}
1017
- }
1164
+ if (forgetCredentials) {
1165
+ const configured = previous ?? (() => {
1166
+ const command = activeSshCommand();
1167
+ if (!command) return undefined;
1168
+ try { return parseSshCommand(command); } catch { return undefined; }
1169
+ })();
1170
+ if (configured) {
1171
+ deleteCachedPassword(configured);
1172
+ deleteCachedKeyPassphrase(configured);
1018
1173
  }
1019
1174
  }
1020
1175
  previous?.client.end();
1021
1176
  status(ctx);
1022
1177
  persistSessionRemoteState();
1023
- ctx.ui.notify(forgetPassword ? "SSH remote disconnected and cached password cleared" : "SSH remote mode disabled (password remains cached in memory only)", "info");
1178
+ ctx.ui.notify(forgetCredentials ? "SSH remote disconnected and cached credentials cleared" : "SSH remote mode disabled (credentials remain cached in memory only)", "info");
1024
1179
  };
1025
1180
 
1026
1181
  const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
@@ -1137,22 +1292,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1137
1292
  });
1138
1293
 
1139
1294
  pi.registerTool({
1140
- name: "ssh_remote_control",
1141
- label: "SSH Remote Control",
1142
- description: "Connect, reconnect, annotate endpoints, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords are never accepted as arguments and are cached only in process memory.",
1295
+ name: "remote",
1296
+ 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.",
1143
1298
  promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
1144
1299
  promptGuidelines: [
1145
- "Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
1146
- "Use ssh_remote_control with action chdir when the user asks to change the remote working directory; do not emulate a persistent directory change with action exec and a one-command cwd.",
1147
- `Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
1148
- "Keep ssh_remote_control exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
1149
- "Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
1300
+ "Use remote when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
1301
+ "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
+ `Always set timeout for remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
1303
+ "Keep remote exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
1304
+ "Use remote with action disconnect after remote work when the user asks to return to the local environment.",
1150
1305
  ],
1151
1306
  parameters: Type.Object({
1152
1307
  action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
1153
- command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22; optionally selects the endpoint for note or memory" })),
1308
+ 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" })),
1154
1309
  note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
1155
- memory: Type.Optional(Type.String({ description: "Persistent server-specific context for the memory action; omit or use an empty string to clear it" })),
1310
+ memory: Type.Optional(Type.String({ description: "Persistent context shared by the endpoint's user@host across SSH ports; omit or use an empty string to clear it" })),
1156
1311
  cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
1157
1312
  forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
1158
1313
  remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
@@ -1167,7 +1322,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1167
1322
  }
1168
1323
  if (params.action === "disconnect" || params.action === "forget") {
1169
1324
  disconnect(ctx, params.action === "forget");
1170
- return { content: [{ type: "text", text: params.action === "forget" ? "Disconnected and forgot the cached password." : "Disconnected from SSH remote and returned to local tools." }], details: { connected: false } };
1325
+ return { content: [{ type: "text", text: params.action === "forget" ? "Disconnected and forgot the cached credentials." : "Disconnected from SSH remote and returned to local tools." }], details: { connected: false } };
1171
1326
  }
1172
1327
  if (params.action === "reconnect") {
1173
1328
  if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
@@ -1212,10 +1367,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1212
1367
  };
1213
1368
  }
1214
1369
  const memory = params.memory?.trim() || undefined;
1215
- saveEndpointConfig(command, { memory });
1370
+ const server = serverMemoryId(parseSshCommand(command));
1371
+ saveServerMemory(command, memory);
1216
1372
  return {
1217
- content: [{ type: "text", text: memory ? `SSH remote server memory updated (${label}). It will be injected while this endpoint is the active remote workspace.` : `SSH remote server memory cleared (${label}).` }],
1218
- details: { endpoint: label, memory },
1373
+ content: [{ type: "text", text: memory ? `SSH remote server memory updated (${server}). It applies to every port for this user and host.` : `SSH remote server memory cleared (${server}).` }],
1374
+ details: { endpoint: label, server, memory },
1219
1375
  };
1220
1376
  }
1221
1377
  if (params.action === "exec") {
@@ -1262,7 +1418,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1262
1418
  });
1263
1419
 
1264
1420
  pi.registerCommand("remote", {
1265
- description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
1421
+ 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",
1266
1422
  handler: async (args, ctx) => {
1267
1423
  const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
1268
1424
  const action = input.toLowerCase();
@@ -1270,7 +1426,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1270
1426
  const config = loadRemoteConfig();
1271
1427
  const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
1272
1428
  const active = key === config.activeEndpoint ? "*" : " ";
1273
- return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${endpoint.memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
1429
+ const command = endpoint.sshCommand || commandFromEndpointKey(key);
1430
+ const memory = command ? endpointMemory(parseSshCommand(command), config) : undefined;
1431
+ 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"}`;
1274
1432
  });
1275
1433
  const limits = configuredOutputLimits(config);
1276
1434
  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");
@@ -1330,8 +1488,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1330
1488
  const command = lastCommand || activeSshCommand();
1331
1489
  if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
1332
1490
  const memory = value.toLowerCase() === "--clear" ? undefined : value;
1333
- saveEndpointConfig(command, { memory });
1334
- ctx.ui.notify(memory ? `SSH remote server memory updated (${parseSshCommand(command).label}); it will be injected while connected` : `SSH remote server memory cleared (${parseSshCommand(command).label})`, "info");
1491
+ const server = serverMemoryId(parseSshCommand(command));
1492
+ saveServerMemory(command, memory);
1493
+ ctx.ui.notify(memory ? `SSH remote server memory updated (${server}); it applies to every port` : `SSH remote server memory cleared (${server})`, "info");
1335
1494
  return;
1336
1495
  }
1337
1496
  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.7",
3
+ "version": "0.1.8",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",