pi-ssh-remote 0.1.6 → 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,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.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
+
37
+ ## [0.1.7] - 2026-08-05
38
+
39
+ ### Added
40
+
41
+ - Added non-secret, session-scoped SSH workspace state for endpoint, remote directory, tool-routing mode, and port forwards.
42
+
43
+ ### Changed
44
+
45
+ - `/new` now inherits the active SSH workspace, forks and clones retain their source workspace, and `/resume` restores the selected historical session's recorded workspace.
46
+ - Session changes close the previous transport cleanly and reconnect the target workspace without storing passwords or other credentials in session files.
47
+
48
+ ### Fixed
49
+
50
+ - None.
51
+
21
52
  ## [0.1.6] - 2026-08-05
22
53
 
23
54
  ### Added
@@ -130,7 +161,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
130
161
 
131
162
  - None.
132
163
 
133
- [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...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
166
+ [0.1.7]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.6...v0.1.7
134
167
  [0.1.6]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.5...v0.1.6
135
168
  [0.1.5]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.4...v0.1.5
136
169
  [0.1.4]: https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.4
package/README.md CHANGED
@@ -27,15 +27,15 @@ 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
 
38
- Dropped connections are automatically re-established during the active session. Remote commands have a 30-second default timeout. Remote text reads fetch focused ranges instead of downloading complete files, command output is streamed through bounded buffers, and a 32 KB per-turn budget prevents parallel tools from flooding model context. Complete oversized command output is preserved in a permission-restricted temporary file.
38
+ Dropped connections are automatically re-established during the active session. SSH workspace state is also session-aware: `/new` inherits the current workspace, forks and clones retain their source workspace, and `/resume` restores the selected session's recorded endpoint, remote directory, routing mode, and forwards. Remote commands have a 30-second default timeout. Remote text reads fetch focused ranges instead of downloading complete files, command output is streamed through bounded buffers, and a 32 KB per-turn budget prevents parallel tools from flooding model context. Complete oversized command output is preserved in a permission-restricted temporary file.
39
39
 
40
40
  ### Hybrid local/remote workflows
41
41
 
@@ -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 values include endpoints, active endpoint, notes, server-specific memories, remote working directories, forwards, preview settings, and model-output budgets. Server memory is user-configured trusted context and is inserted into each model request only while that endpoint is the active remote workspace; it is not read from the remote server. Passwords are **never written to this file**: SSH agent authentication is preferred, and prompted passwords remain only in process memory.
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.6](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.6)
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 |
223
+ | [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | Session-aware SSH workspace restoration for new, forked, and resumed sessions |
212
224
  | [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | Bounded remote reads, streamed command output, and per-turn output budgets |
213
- | [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | Endpoint-specific server memory and automatic context injection |
214
- | [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | Endpoint notes and enforced remote command timeouts |
215
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,11 +54,11 @@ 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
 
61
- 当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。
61
+ 当前会话中如果 SSH 连接意外断开,插件会尝试自动重连,不需要 Agent 从头建立工作环境。SSH 工作区状态也会跟随 Pi session:`/new` 继承当前工作区,`/fork` 和 `/clone` 保留来源工作区,`/resume` 恢复目标历史 session 记录的服务器、远程目录、路由模式和端口转发。
62
62
 
63
63
  ### 对 Agent 上下文更友好
64
64
 
@@ -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
 
@@ -249,7 +259,7 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
249
259
  ~/.pi/agent/ssh-remote-config.json
250
260
  ```
251
261
 
252
- 其中包括:
262
+ 全局配置包括:
253
263
 
254
264
  - 已保存的服务器;
255
265
  - 当前选中的服务器;
@@ -257,12 +267,13 @@ Pi 会通过插件提供的 `ssh_remote_control` 工具完成这些操作。
257
267
  - 服务器特定记忆;
258
268
  - 默认远程目录;
259
269
  - 端口转发配置;
270
+ - 私钥文件路径;
260
271
  - 命令预览设置;
261
272
  - 模型输出预算。
262
273
 
263
- 服务器记忆是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当对应 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.6](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.6)
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` 工具名 |
302
+ | [0.1.7](CHANGELOG.md#017---2026-08-05) | 2026-08-05 | 新建、分叉和恢复 session 时自动恢复对应 SSH 工作区 |
290
303
  | [0.1.6](CHANGELOG.md#016---2026-08-05) | 2026-08-05 | 有界远端读取、流式命令输出与每轮输出预算 |
291
- | [0.1.5](CHANGELOG.md#015---2026-08-04) | 2026-08-04 | endpoint 级服务器记忆与自动上下文注入 |
292
- | [0.1.4](CHANGELOG.md#014---2026-07-31) | 2026-07-31 | endpoint 备注与远程命令强制超时 |
293
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,22 +33,26 @@ 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>;
51
- resume?: { command: string; cwd: string };
54
+ keyPassphrases: Map<string, string>;
55
+ resume?: { command: string; cwd: string; routeRemoteTools: boolean; forwards?: string[] };
52
56
  }
53
57
 
54
58
  interface RemoteEndpointConfig {
@@ -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;
@@ -80,6 +86,15 @@ interface ForwardSpec {
80
86
  remotePort: number;
81
87
  }
82
88
 
89
+ interface SessionRemoteState {
90
+ version: 1;
91
+ connected: boolean;
92
+ command?: string;
93
+ cwd?: string;
94
+ routeRemoteTools?: boolean;
95
+ forwards?: string[];
96
+ }
97
+
83
98
  const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
84
99
  const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
85
100
  const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
@@ -95,9 +110,12 @@ const MIN_MODEL_OUTPUT_BYTES = 1024;
95
110
  const OUTPUT_FOOTER_RESERVE_BYTES = 512;
96
111
  const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
97
112
  const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
113
+ const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
114
+ const SESSION_STATE_ENTRY_TYPE = "pi-ssh-remote-state";
98
115
  const CACHE_KEY = "__piHpcCredentialCacheV1";
99
116
  const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
100
- 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>();
101
119
 
102
120
  function shellWords(input: string): string[] {
103
121
  const words: string[] = [];
@@ -122,28 +140,47 @@ function parseSshCommand(command: string): ParsedSsh {
122
140
  if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
123
141
  let port = 22;
124
142
  let username = process.env.USER || "root";
143
+ let identityFile: string | undefined;
125
144
  let target: string | undefined;
126
145
  for (let i = 1; i < args.length; i++) {
127
146
  const arg = args[i]!;
128
147
  if (arg === "-p") { port = Number(args[++i]); continue; }
129
148
  if (arg.startsWith("-p") && arg.length > 2) { port = Number(arg.slice(2)); continue; }
130
149
  if (arg === "-l") { username = args[++i] || username; continue; }
131
- 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`);
132
162
  if (!target) target = arg;
133
163
  else throw new Error("Unexpected extra argument in SSH command");
134
164
  }
135
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
+ }
136
169
  const at = target.lastIndexOf("@");
137
170
  const host = at >= 0 ? target.slice(at + 1) : target;
138
171
  if (at >= 0) username = target.slice(0, at);
139
172
  if (!host || !username) throw new Error("Invalid SSH username or host");
140
- return { host, port, username, label: `${username}@${host}:${port}`, command };
173
+ return { host, port, username, ...(identityFile ? { identityFile } : {}), label: `${username}@${host}:${port}`, command };
141
174
  }
142
175
 
143
176
  function cacheId(config: ParsedSsh): string {
144
177
  return `${config.username}@${config.host}:${config.port}`;
145
178
  }
146
179
 
180
+ function serverMemoryId(config: ParsedSsh): string {
181
+ return `${config.username}@${config.host}`;
182
+ }
183
+
147
184
  function getCachedPassword(config: ParsedSsh): string | undefined {
148
185
  return credentialCache.passwords.get(cacheId(config));
149
186
  }
@@ -156,6 +193,60 @@ function deleteCachedPassword(config: ParsedSsh): void {
156
193
  credentialCache.passwords.delete(cacheId(config));
157
194
  }
158
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
+
159
250
  function parseForwardSpec(value: string): ForwardSpec {
160
251
  const match = value.match(/^(\d+):([^:]+):(\d+)$/);
161
252
  if (!match) throw new Error(`Invalid port-forward specification: ${value}; expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT`);
@@ -231,11 +322,25 @@ function commandFromEndpointKey(key: string): string | undefined {
231
322
 
232
323
  function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
233
324
  const endpoints = { ...(config.endpoints ?? {}) };
234
- 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;
235
336
  endpoints[key] = {
236
- ...endpoint,
237
- ...(endpoint.sshCommand ? {} : { sshCommand: commandFromEndpointKey(key) }),
337
+ ...endpointWithoutMemory,
338
+ ...(command ? { sshCommand: command } : {}),
238
339
  };
340
+ if (legacyMemory?.trim() && command) {
341
+ try { serverMemories[serverMemoryId(parseSshCommand(command))] ??= legacyMemory.trim(); }
342
+ catch {}
343
+ }
239
344
  }
240
345
 
241
346
  let activeEndpoint = config.activeEndpoint;
@@ -269,6 +374,7 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
269
374
  return {
270
375
  ...(activeEndpoint ? { activeEndpoint } : {}),
271
376
  ...(Object.keys(endpoints).length ? { endpoints } : {}),
377
+ ...(Object.keys(serverMemories).length ? { serverMemories } : {}),
272
378
  ...(displayLines !== undefined ? { displayLines } : {}),
273
379
  ...(readMaxLines !== undefined ? { readMaxLines } : {}),
274
380
  ...(readMaxBytes !== undefined ? { readMaxBytes } : {}),
@@ -307,13 +413,13 @@ function endpointDisplayLabel(endpoint: ParsedSsh, config = loadRemoteConfig()):
307
413
  }
308
414
 
309
415
  function endpointMemory(endpoint: ParsedSsh, config = loadRemoteConfig()): string | undefined {
310
- return endpointConfig(config, endpoint.command).memory?.trim() || undefined;
416
+ return config.serverMemories?.[serverMemoryId(endpoint)]?.trim() || undefined;
311
417
  }
312
418
 
313
419
  function remoteSystemPrompt(systemPrompt: string, localCwd: string, remote: RemoteState): string {
314
420
  return systemPrompt.replace(
315
421
  `Current working directory: ${localCwd}`,
316
- `Current working directory: ${remote.cwd} (via SSH ${endpointDisplayLabel(remote)}). All read, write, edit, bash, and user shell operations run on this remote server. Use ssh_remote_control with action disconnect to return to the local environment when requested.`,
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.`,
317
423
  );
318
424
  }
319
425
 
@@ -336,6 +442,24 @@ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, make
336
442
  });
337
443
  }
338
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
+
339
463
  function loadKnownHosts(): Record<string, string> {
340
464
  try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
341
465
  catch { return {}; }
@@ -508,15 +632,16 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
508
632
  });
509
633
  }
510
634
 
511
- 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> {
512
638
  return new Promise((resolve, reject) => {
513
639
  const client = new Client();
514
640
  const options: ConnectConfig = {
515
641
  host: config.host,
516
642
  port: config.port,
517
643
  username: config.username,
518
- ...(password ? { password } : {}),
519
- ...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
644
+ ...authentication,
520
645
  readyTimeout: 12000,
521
646
  keepaliveInterval: 15000,
522
647
  keepaliveCountMax: 3,
@@ -530,7 +655,7 @@ function connect(config: ParsedSsh, password: string | undefined, fingerprint: s
530
655
  }
531
656
 
532
657
  function execRemote(
533
- client: Client,
658
+ client: SshClient,
534
659
  command: string,
535
660
  allowFailure = false,
536
661
  timeoutSeconds = DEFAULT_REMOTE_TIMEOUT_SECONDS,
@@ -564,7 +689,7 @@ function execRemote(
564
689
  }
565
690
 
566
691
  function execRemoteLimited(
567
- client: Client,
692
+ client: SshClient,
568
693
  command: string,
569
694
  timeoutSeconds: number,
570
695
  maxLines: number,
@@ -610,11 +735,11 @@ function execRemoteLimited(
610
735
  });
611
736
  }
612
737
 
613
- function getSftp(client: Client): Promise<SFTPWrapper> {
738
+ function getSftp(client: SshClient): Promise<SFTPWrapper> {
614
739
  return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
615
740
  }
616
741
 
617
- 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> {
618
743
  const sftp = await getSftp(client);
619
744
  try { return await operation(sftp); }
620
745
  finally { sftp.end(); }
@@ -625,10 +750,10 @@ function isReconnectable(error: unknown): boolean {
625
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);
626
751
  }
627
752
 
628
- class PasswordInput implements Component, Focusable {
753
+ class SecretInput implements Component, Focusable {
629
754
  focused = false;
630
755
  private value = "";
631
- constructor(private done: (value: string | null) => void, private renderNow: () => void) {}
756
+ constructor(private label: string, private done: (value: string | null) => void, private renderNow: () => void) {}
632
757
  handleInput(data: string): void {
633
758
  if (matchesKey(data, Key.enter)) return this.done(this.value);
634
759
  if (matchesKey(data, Key.escape)) return this.done(null);
@@ -641,15 +766,19 @@ class PasswordInput implements Component, Focusable {
641
766
  this.renderNow();
642
767
  }
643
768
  render(width: number): string[] {
644
- 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, "")];
645
770
  }
646
771
  invalidate(): void {}
647
772
  }
648
773
 
649
- async function askPassword(ctx: any): Promise<string | null> {
650
- 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;
651
776
  return ctx.ui.custom<string | null>((tui: any, _theme: any, _keys: any, done: (value: string | null) => void) =>
652
- 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");
653
782
  }
654
783
 
655
784
  export default function sshRemoteExtension(pi: ExtensionAPI) {
@@ -659,6 +788,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
659
788
  let currentCtx: any;
660
789
  let reconnectPromise: Promise<RemoteState> | null = null;
661
790
  const forwardServers = new Map<number, Server>();
791
+ const forwardSpecs = new Map<number, ForwardSpec>();
792
+ let sessionReady = false;
793
+ let restoringSessionState = false;
662
794
  let lastConnectionError: string | undefined;
663
795
  let lastCommand = credentialCache.resume?.command ?? activeSshCommand() ?? "";
664
796
  let turnOutputBytes = 0;
@@ -669,6 +801,33 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
669
801
  const configuredForwards = (command: string): string[] =>
670
802
  endpointConfig(loadRemoteConfig(), command).forwards ?? [];
671
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
+
672
831
  const mapPath = (path: string): string => {
673
832
  if (!remote) return path;
674
833
  if (path === localCwd) return remote.cwd;
@@ -684,6 +843,42 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
684
843
  return posix.join(remote.cwd, normalized);
685
844
  };
686
845
 
846
+ const serializeForward = (spec: ForwardSpec): string => `${spec.localPort}:${spec.remoteHost}:${spec.remotePort}`;
847
+
848
+ const currentSessionRemoteState = (): SessionRemoteState => remote ? {
849
+ version: 1,
850
+ connected: true,
851
+ command: remote.command,
852
+ cwd: remote.cwd,
853
+ routeRemoteTools,
854
+ forwards: [...forwardSpecs.values()].map(serializeForward),
855
+ } : { version: 1, connected: false };
856
+
857
+ const persistSessionRemoteState = (): void => {
858
+ if (!sessionReady || restoringSessionState) return;
859
+ pi.appendEntry(SESSION_STATE_ENTRY_TYPE, currentSessionRemoteState());
860
+ };
861
+
862
+ const loadSessionRemoteState = (ctx: any): SessionRemoteState | undefined => {
863
+ const branch = ctx.sessionManager.getBranch();
864
+ for (let index = branch.length - 1; index >= 0; index--) {
865
+ const entry = branch[index] as any;
866
+ if (entry.type !== "custom" || entry.customType !== SESSION_STATE_ENTRY_TYPE) continue;
867
+ const data = entry.data as Partial<SessionRemoteState> | undefined;
868
+ if (!data || data.version !== 1 || typeof data.connected !== "boolean") return undefined;
869
+ if (data.connected && (typeof data.command !== "string" || typeof data.cwd !== "string")) return undefined;
870
+ return {
871
+ version: 1,
872
+ connected: data.connected,
873
+ ...(data.command ? { command: data.command } : {}),
874
+ ...(data.cwd ? { cwd: data.cwd } : {}),
875
+ ...(typeof data.routeRemoteTools === "boolean" ? { routeRemoteTools: data.routeRemoteTools } : {}),
876
+ ...(Array.isArray(data.forwards) ? { forwards: data.forwards.filter((value): value is string => typeof value === "string") } : {}),
877
+ };
878
+ }
879
+ return undefined;
880
+ };
881
+
687
882
  const limitRemoteToolResult = (result: any, kind: "read" | "exec", startLine = 1, requestedMaxLines?: number) => {
688
883
  const limits = configuredOutputLimits();
689
884
  const configuredMaxBytes = kind === "read" ? limits.readMaxBytes : limits.execMaxBytes;
@@ -753,11 +948,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
753
948
  });
754
949
  };
755
950
 
756
- const establish = async (parsed: ParsedSsh, password: string | undefined, cwd: string): Promise<RemoteState> => {
951
+ const establish = async (parsed: ParsedSsh, authentication: SshAuthentication, cwd: string): Promise<RemoteState> => {
757
952
  const key = `${parsed.host}:${parsed.port}`;
758
953
  const fingerprint = loadKnownHosts()[key];
759
954
  if (!fingerprint) throw new Error(`Host ${key} is not trusted; connect interactively with /remote first`);
760
- const client = await connect(parsed, password, fingerprint);
955
+ const client = await connect(parsed, authentication, fingerprint);
761
956
  try {
762
957
  const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
763
958
  const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
@@ -772,15 +967,20 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
772
967
 
773
968
  async function reconnectRemote(): Promise<RemoteState> {
774
969
  if (reconnectPromise) return reconnectPromise;
970
+ const resumeRouting = remote ? routeRemoteTools : (credentialCache.resume?.routeRemoteTools ?? true);
775
971
  const source = remote ?? (credentialCache.resume ? { ...parseSshCommand(credentialCache.resume.command), cwd: credentialCache.resume.cwd } : null);
776
972
  if (!source) throw new Error("No SSH remote connection is available to reconnect");
777
973
  const parsed = parseSshCommand(source.command);
778
974
  const password = getCachedPassword(parsed);
779
975
  reconnectPromise = (async () => {
976
+ const authentication = parsed.identityFile
977
+ ? await privateKeyAuthentication(parsed)
978
+ : standardAuthentication(password);
780
979
  const oldClient = remote?.client;
781
- const next = await establish(parsed, password, source.cwd);
980
+ const next = await establish(parsed, authentication, source.cwd);
782
981
  remote = next;
783
- credentialCache.resume = { command: parsed.command, cwd: next.cwd };
982
+ routeRemoteTools = resumeRouting;
983
+ credentialCache.resume = { command: parsed.command, cwd: next.cwd, routeRemoteTools, forwards: credentialCache.resume?.forwards };
784
984
  oldClient?.end();
785
985
  if (currentCtx) {
786
986
  status(currentCtx);
@@ -791,7 +991,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
791
991
  return reconnectPromise;
792
992
  }
793
993
 
794
- const withReconnect = async <T>(operation: (client: Client) => Promise<T>): Promise<T> => {
994
+ const withReconnect = async <T>(operation: (client: SshClient) => Promise<T>): Promise<T> => {
795
995
  if (!remote) throw new Error("SSH remote is not connected");
796
996
  try { return await operation(remote.client); }
797
997
  catch (error) {
@@ -810,9 +1010,10 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
810
1010
  `cd -- ${quote(remote!.cwd)} && ${targetCommand} && pwd -P`,
811
1011
  ))).toString().trim();
812
1012
  remote.cwd = resolved;
813
- credentialCache.resume = { command: remote.command, cwd: resolved };
1013
+ credentialCache.resume = { command: remote.command, cwd: resolved, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
814
1014
  saveEndpointConfig(remote.command, { remoteCwd: resolved }, true);
815
1015
  status(ctx);
1016
+ persistSessionRemoteState();
816
1017
  return resolved;
817
1018
  };
818
1019
 
@@ -856,33 +1057,38 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
856
1057
  let password = getCachedPassword(parsed);
857
1058
  ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${endpointDisplayLabel(parsed)}…`));
858
1059
  try {
1060
+ let authentication = parsed.identityFile
1061
+ ? await privateKeyAuthentication(parsed, ctx)
1062
+ : standardAuthentication(password);
859
1063
  let next: RemoteState;
860
1064
  try {
861
- next = await establish(parsed, password, cwd ?? configuredCwd(command));
1065
+ next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
862
1066
  } catch (error) {
863
- 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;
864
1068
  password = await askPassword(ctx) ?? undefined;
865
1069
  if (!password) {
866
1070
  lastConnectionError = "No SSH password was provided and SSH agent authentication failed";
867
1071
  status(ctx);
868
1072
  return null;
869
1073
  }
870
- next = await establish(parsed, password, cwd ?? configuredCwd(command));
1074
+ authentication = standardAuthentication(password);
1075
+ next = await establish(parsed, authentication, cwd ?? configuredCwd(command));
871
1076
  }
872
1077
  const previous = remote?.client;
873
1078
  remote = next;
874
1079
  routeRemoteTools = true;
875
1080
  previous?.end();
876
- if (password) setCachedPassword(parsed, password);
877
- credentialCache.resume = { command, cwd: next.cwd };
1081
+ if (!parsed.identityFile && password) setCachedPassword(parsed, password);
1082
+ credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [] };
878
1083
  lastCommand = command;
879
1084
  lastConnectionError = undefined;
880
1085
  saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
881
1086
  status(ctx);
1087
+ persistSessionRemoteState();
882
1088
  ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
883
1089
  return next;
884
1090
  } catch (error) {
885
- deleteCachedPassword(parsed);
1091
+ if (!parsed.identityFile) deleteCachedPassword(parsed);
886
1092
  remote = null;
887
1093
  lastConnectionError = (error as Error).message;
888
1094
  status(ctx);
@@ -895,7 +1101,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
895
1101
  if (remote) return remote;
896
1102
  if (credentialCache.resume) return reconnectRemote();
897
1103
  const command = lastCommand || activeSshCommand();
898
- 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]");
899
1105
  const state = await connectInteractive(command, ctx, configuredCwd(command));
900
1106
  if (!state) throw new Error("SSH remote connection was cancelled or failed");
901
1107
  return state;
@@ -922,33 +1128,54 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
922
1128
  });
923
1129
  });
924
1130
  forwardServers.set(spec.localPort, server);
1131
+ forwardSpecs.set(spec.localPort, spec);
925
1132
  };
926
1133
 
927
1134
  const stopForwards = async (): Promise<void> => {
928
1135
  const servers = [...forwardServers.values()];
929
1136
  forwardServers.clear();
1137
+ forwardSpecs.clear();
930
1138
  await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
931
1139
  };
932
1140
 
933
- const disconnect = (ctx: any, forgetPassword = false) => {
1141
+ const restoreSessionRemoteState = async (saved: SessionRemoteState, ctx: any): Promise<void> => {
1142
+ if (!saved.connected) {
1143
+ credentialCache.resume = undefined;
1144
+ status(ctx);
1145
+ return;
1146
+ }
1147
+ const command = saved.command!;
1148
+ const next = await connectInteractive(command, ctx, saved.cwd);
1149
+ if (!next) throw new Error(lastConnectionError || "SSH remote session restore was cancelled or failed");
1150
+ routeRemoteTools = saved.routeRemoteTools ?? true;
1151
+ const specs = (saved.forwards ?? []).map(parseForwardSpec);
1152
+ for (const spec of specs) await startForward(spec);
1153
+ credentialCache.resume = { command, cwd: next.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
1154
+ status(ctx);
1155
+ };
1156
+
1157
+ const disconnect = (ctx: any, forgetCredentials = false) => {
934
1158
  const previous = remote;
935
1159
  remote = null;
936
1160
  routeRemoteTools = false;
937
1161
  reconnectPromise = null;
938
1162
  credentialCache.resume = undefined;
939
1163
  void stopForwards();
940
- if (forgetPassword) {
941
- if (previous) deleteCachedPassword(previous);
942
- else {
943
- const configured = activeSshCommand();
944
- if (configured) {
945
- try { deleteCachedPassword(parseSshCommand(configured)); } catch {}
946
- }
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);
947
1173
  }
948
1174
  }
949
1175
  previous?.client.end();
950
1176
  status(ctx);
951
- ctx.ui.notify(forgetPassword ? "SSH remote disconnected and cached password cleared" : "SSH remote mode disabled (password remains cached in memory only)", "info");
1177
+ persistSessionRemoteState();
1178
+ ctx.ui.notify(forgetCredentials ? "SSH remote disconnected and cached credentials cleared" : "SSH remote mode disabled (credentials remain cached in memory only)", "info");
952
1179
  };
953
1180
 
954
1181
  const detectRemoteMimeType = async (path: string): Promise<string | undefined> => {
@@ -1065,22 +1292,22 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1065
1292
  });
1066
1293
 
1067
1294
  pi.registerTool({
1068
- name: "ssh_remote_control",
1069
- label: "SSH Remote Control",
1070
- description: "Connect, reconnect, annotate endpoints, manage server-specific memory, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output is streamed to bounded buffers; model output defaults to the last 200 lines or 8KB, while complete oversized output is saved locally. Passwords are never accepted as arguments and are cached only in process memory.",
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.",
1071
1298
  promptSnippet: "Control the configured remote SSH connection, endpoint note and memory, working directory, and local port forwarding",
1072
1299
  promptGuidelines: [
1073
- "Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
1074
- "Use ssh_remote_control with action chdir when the user asks to change the remote working directory; do not emulate a persistent directory change with action exec and a one-command cwd.",
1075
- `Always set timeout for ssh_remote_control remote exec commands; it defaults to ${DEFAULT_REMOTE_TIMEOUT_SECONDS} seconds when omitted.`,
1076
- "Keep ssh_remote_control exec output narrow with tail, sed, rg limits, or similarly bounded commands; never cat large logs or emit broad file listings.",
1077
- "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.",
1078
1305
  ],
1079
1306
  parameters: Type.Object({
1080
1307
  action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir", "note", "memory"] as const),
1081
- command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22; optionally selects the endpoint for note or memory" })),
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" })),
1082
1309
  note: Type.Optional(Type.String({ description: "Endpoint note for the note action; omit or use an empty string to clear it" })),
1083
- memory: Type.Optional(Type.String({ description: "Persistent server-specific context for the memory action; omit or use an empty string to clear it" })),
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" })),
1084
1311
  cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
1085
1312
  forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
1086
1313
  remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
@@ -1095,7 +1322,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1095
1322
  }
1096
1323
  if (params.action === "disconnect" || params.action === "forget") {
1097
1324
  disconnect(ctx, params.action === "forget");
1098
- 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 } };
1099
1326
  }
1100
1327
  if (params.action === "reconnect") {
1101
1328
  if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
@@ -1104,6 +1331,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1104
1331
  }
1105
1332
  if (params.action === "unforward") {
1106
1333
  await stopForwards();
1334
+ persistSessionRemoteState();
1107
1335
  return { content: [{ type: "text", text: "Closed all extension-managed SSH port forwards." }], details: { forwardedPorts: [] } };
1108
1336
  }
1109
1337
  if (params.action === "forward") {
@@ -1113,7 +1341,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1113
1341
  const specs = values.map(parseForwardSpec);
1114
1342
  for (const spec of specs) await startForward(spec);
1115
1343
  routeRemoteTools = false;
1344
+ credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
1116
1345
  if (currentCtx) status(currentCtx);
1346
+ persistSessionRemoteState();
1117
1347
  const ports = specs.map((spec) => spec.localPort);
1118
1348
  return { content: [{ type: "text", text: `Forwarded local ports: ${ports.join(", ")}; tools remain local.` }], details: { toolRouting: "local", forwardedPorts: ports } };
1119
1349
  }
@@ -1137,10 +1367,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1137
1367
  };
1138
1368
  }
1139
1369
  const memory = params.memory?.trim() || undefined;
1140
- saveEndpointConfig(command, { memory });
1370
+ const server = serverMemoryId(parseSshCommand(command));
1371
+ saveServerMemory(command, memory);
1141
1372
  return {
1142
- content: [{ type: "text", text: memory ? `SSH remote server memory updated (${label}). It will be injected while this endpoint is the active remote workspace.` : `SSH remote server memory cleared (${label}).` }],
1143
- details: { endpoint: label, memory },
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 },
1144
1375
  };
1145
1376
  }
1146
1377
  if (params.action === "exec") {
@@ -1187,7 +1418,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1187
1418
  });
1188
1419
 
1189
1420
  pi.registerCommand("remote", {
1190
- description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config note TEXT|--clear | config memory TEXT|--clear | config cwd PATH | config display-lines N | config read-max-lines|read-max-bytes|exec-max-lines|exec-max-bytes|turn-max-bytes N | forward [MAPPINGS] | unforward | exec [--timeout SECONDS] [--lines N] COMMAND | cd PATH | status | reload | off | forget",
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",
1191
1422
  handler: async (args, ctx) => {
1192
1423
  const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
1193
1424
  const action = input.toLowerCase();
@@ -1195,7 +1426,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1195
1426
  const config = loadRemoteConfig();
1196
1427
  const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
1197
1428
  const active = key === config.activeEndpoint ? "*" : " ";
1198
- return `${active} ${key}\n note: ${endpoint.note || "none"}\n memory: ${endpoint.memory || "none"}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
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"}`;
1199
1432
  });
1200
1433
  const limits = configuredOutputLimits(config);
1201
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");
@@ -1231,6 +1464,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1231
1464
  previous.client.end();
1232
1465
  await stopForwards();
1233
1466
  status(ctx);
1467
+ persistSessionRemoteState();
1234
1468
  }
1235
1469
  saveRemoteConfig({ ...config, activeEndpoint: key });
1236
1470
  lastCommand = command;
@@ -1254,8 +1488,9 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1254
1488
  const command = lastCommand || activeSshCommand();
1255
1489
  if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
1256
1490
  const memory = value.toLowerCase() === "--clear" ? undefined : value;
1257
- saveEndpointConfig(command, { memory });
1258
- ctx.ui.notify(memory ? `SSH remote server memory updated (${parseSshCommand(command).label}); it will be injected while connected` : `SSH remote server memory cleared (${parseSshCommand(command).label})`, "info");
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");
1259
1494
  return;
1260
1495
  }
1261
1496
  if (/^config\s+display-lines\s+/i.test(input)) {
@@ -1309,13 +1544,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1309
1544
  const specs = values.map(parseForwardSpec);
1310
1545
  for (const spec of specs) await startForward(spec);
1311
1546
  routeRemoteTools = false;
1547
+ credentialCache.resume = { command: state.command, cwd: state.cwd, routeRemoteTools, forwards: [...forwardSpecs.values()].map(serializeForward) };
1312
1548
  status(ctx);
1549
+ persistSessionRemoteState();
1313
1550
  ctx.ui.notify(`SSH remote port forwarding started; tools remain local in ${localCwd}: ${specs.map((spec) => `127.0.0.1:${spec.localPort}`).join(", ")}`, "info");
1314
1551
  } catch (error) { ctx.ui.notify(`SSH remote port forwarding failed: ${(error as Error).message}`, "error"); }
1315
1552
  return;
1316
1553
  }
1317
1554
  if (action === "unforward") {
1318
1555
  await stopForwards();
1556
+ persistSessionRemoteState();
1319
1557
  ctx.ui.notify("Closed all extension-managed SSH remote port forwards", "info");
1320
1558
  return;
1321
1559
  }
@@ -1378,19 +1616,50 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1378
1616
  pi.on("turn_start", () => { turnOutputBytes = 0; });
1379
1617
  pi.on("session_start", async (event, ctx) => {
1380
1618
  currentCtx = ctx;
1619
+ sessionReady = true;
1381
1620
  status(ctx);
1382
- if (event.reason === "reload" && credentialCache.resume) {
1383
- try { await reconnectRemote(); }
1384
- catch (error) { ctx.ui.notify(`SSH remote automatic login after reload failed: ${(error as Error).message}`, "error"); }
1621
+ const saved = loadSessionRemoteState(ctx);
1622
+ const canInheritCurrent = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
1623
+ const inherited = !saved && canInheritCurrent && credentialCache.resume ? credentialCache.resume : undefined;
1624
+ const target: SessionRemoteState | undefined = saved ?? (inherited ? {
1625
+ version: 1,
1626
+ connected: true,
1627
+ command: inherited.command,
1628
+ cwd: inherited.cwd,
1629
+ routeRemoteTools: inherited.routeRemoteTools,
1630
+ forwards: inherited.forwards ?? [],
1631
+ } : undefined);
1632
+ if (!target) {
1633
+ credentialCache.resume = undefined;
1634
+ return;
1635
+ }
1636
+ restoringSessionState = true;
1637
+ try {
1638
+ await restoreSessionRemoteState(target, ctx);
1639
+ } catch (error) {
1640
+ ctx.ui.notify(`SSH remote session restore failed: ${(error as Error).message}`, "error");
1641
+ } finally {
1642
+ restoringSessionState = false;
1385
1643
  }
1644
+ if (inherited && remote) persistSessionRemoteState();
1386
1645
  });
1387
- pi.on("session_shutdown", (event) => {
1646
+ pi.on("session_shutdown", async (event) => {
1647
+ sessionReady = false;
1388
1648
  const previous = remote;
1649
+ const preserveConnection = event.reason === "reload" || event.reason === "new" || event.reason === "fork";
1650
+ if (previous && preserveConnection) {
1651
+ credentialCache.resume = {
1652
+ command: previous.command,
1653
+ cwd: previous.cwd,
1654
+ routeRemoteTools,
1655
+ forwards: [...forwardSpecs.values()].map(serializeForward),
1656
+ };
1657
+ }
1389
1658
  remote = null;
1390
1659
  routeRemoteTools = false;
1391
- void stopForwards();
1660
+ await stopForwards();
1392
1661
  previous?.client.end();
1393
- if (event.reason !== "reload") credentialCache.resume = undefined;
1662
+ if (!preserveConnection) credentialCache.resume = undefined;
1394
1663
  });
1395
1664
  pi.on("user_bash", async (event, ctx) => {
1396
1665
  if (!remote || !routeRemoteTools) return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",