pi-ssh-remote 0.1.11 → 0.1.13

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,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
18
18
 
19
19
  - None.
20
20
 
21
+ ## [0.1.13] - 2026-09-18
22
+
23
+ ### Added
24
+
25
+ - None.
26
+
27
+ ### Changed
28
+
29
+ - Increased the SSH handshake timeout to 30 seconds for slower remote connections.
30
+
31
+ ### Fixed
32
+
33
+ - Stored SSH config, known hosts, and server memory in Pi's agent directory (`~/.pi/agent`, or `PI_CODING_AGENT_DIR`) instead of falling back to the current working directory when `HOME` is unset, which mainly affected Windows.
34
+ - Expanded `ssh -i ~/...` identity paths with the user home directory from `os.homedir()`, so private keys resolve on Windows.
35
+ - Fell back to the remote login directory with a warning when a saved remote working directory is unavailable, instead of failing the SSH connection.
36
+
37
+ ## [0.1.12] - 2026-08-25
38
+
39
+ ### Added
40
+
41
+ - None.
42
+
43
+ ### Changed
44
+
45
+ - Simplified the server-memory documentation to focus on adding, listing, updating, and deleting entries without exposing implementation details.
46
+
47
+ ### Fixed
48
+
49
+ - Prevented follow-up SSH client errors during a connection loss before handshake from becoming an uncaught exception that exits Pi.
50
+
21
51
  ## [0.1.11] - 2026-08-16
22
52
 
23
53
  ### Added
@@ -206,7 +236,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
206
236
 
207
237
  - None.
208
238
 
209
- [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...HEAD
239
+ [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.13...HEAD
240
+ [0.1.13]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.12...v0.1.13
241
+ [0.1.12]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...v0.1.12
210
242
  [0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
211
243
  [0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
212
244
  [0.1.9]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.8...v0.1.9
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -31,7 +31,7 @@ The extension exposes `remote` for the agent and `/remote` for the user. You can
31
31
 
32
32
  ### Stateful endpoints instead of disposable SSH commands
33
33
 
34
- Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Server memory is a per-`user@host` JSON file containing stable `{ "id", "content" }` entries, so another SSH port for the same user and host uses the same entries. Pi is told the exact local file path and can use its normal `read`, `edit`, and `write` tools for entry-level CRUD even while other tools are routed remotely. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible. Valid server-memory entries are automatically injected into the model context whenever a matching endpoint is connected as the active remote workspace.
34
+ Each `user@host:port` endpoint remembers its remote working directory, note, and port-forward configuration locally. Notes such as `H100 training`, `staging`, or `customer demo` appear in configuration, status messages, and Pi's footer, so the active execution environment stays visible.
35
35
 
36
36
  ### Resilient and bounded by default
37
37
 
@@ -67,8 +67,6 @@ Requires Node.js 20+. The remote server must provide Bash, SFTP, and GNU `timeou
67
67
  /remote cd /srv/project
68
68
  /remote config note H100 training server
69
69
  /remote status
70
-
71
- You: Add a server-memory entry saying to use /opt/conda/bin/python and never stop shared jobs.
72
70
  ```
73
71
 
74
72
  From this point, normal Pi operations target `/srv/project` on the remote server. The footer makes that routing explicit:
@@ -83,6 +81,29 @@ Return to local tools with:
83
81
  /remote off
84
82
  ```
85
83
 
84
+ ### Server memory
85
+
86
+ Tell Pi what to remember for the current server:
87
+
88
+ ```text
89
+ Remember for this server: use /opt/conda/bin/python and never stop shared jobs.
90
+ ```
91
+
92
+ List saved entries and their IDs:
93
+
94
+ ```text
95
+ /remote memory
96
+ ```
97
+
98
+ Use an ID to update or delete an entry:
99
+
100
+ ```text
101
+ Update memory deploy-command to use /opt/deploy/v2/run.sh.
102
+ Delete memory obsolete-proxy-rule.
103
+ ```
104
+
105
+ Pi automatically applies saved memory when you work on that server. Deletion only happens when you explicitly request it.
106
+
86
107
  ### Private key authentication
87
108
 
88
109
  Pass an explicit local private key with `-i`:
@@ -132,7 +153,7 @@ The default foreground timeout prevents an accidental long-running command from
132
153
  /remote
133
154
  ```
134
155
 
135
- Endpoint notes and working directories survive Pi restarts in the local remote configuration. `user@host`-specific memory survives in a separate local JSON file under `~/.pi/agent/ssh-remote-memories/`; each entry has a stable `id` and `content`. When Pi connects to an endpoint, valid entries matching its user and host are added to the model context on every agent run while remote tool routing remains active; the SSH port does not affect memory selection. Disconnecting or switching to tunnel-only mode removes them from subsequent model requests.
156
+ Endpoint notes and working directories survive Pi restarts and remain separate for each saved endpoint.
136
157
 
137
158
  ### 4. Expose a remote service while editing locally
138
159
 
@@ -165,25 +186,6 @@ Forward the remote service on port 8000 to localhost:8000, but keep my coding
165
186
  tools on the local repository.
166
187
  ```
167
188
 
168
- ```text
169
- Add a server-memory entry that says deployments must use /opt/deploy/run.sh.
170
- ```
171
-
172
- List the current server's entries and IDs before referring to one:
173
-
174
- ```text
175
- /remote memory
176
- ```
177
-
178
- Then update or delete by the displayed ID:
179
-
180
- ```text
181
- Update the entry with ID deploy-command to use /opt/deploy/v2/run.sh.
182
- Delete the server-memory entry with ID obsolete-proxy-rule.
183
- ```
184
-
185
- These requests are handled through the agent-facing `remote` tool; slash commands are optional. The `memory` action locates and inspects the matching JSON file, while Pi's normal `read`, `edit`, and `write` tools perform entry-level add, query, update, and delete operations. The injected instructions make deletion deliberately strict: Pi must first read the file, identify the exact entry ID, and delete only that object—and only after the user explicitly asks to delete, remove, or forget memory. Ambiguous targets require clarification, update requests do not imply deletion, and deleting all entries requires an explicit request to delete all server memory.
186
-
187
189
  ## Command reference
188
190
 
189
191
  | Command | Purpose |
@@ -194,7 +196,7 @@ These requests are handled through the agent-facing `remote` tool; slash command
194
196
  | `/remote use USER@HOST:PORT` | Select a saved endpoint |
195
197
  | `/remote config note TEXT` | Persist a note for the selected endpoint |
196
198
  | `/remote config note --clear` | Clear its note |
197
- | `/remote memory` | List the current server's memory entries, IDs, and JSON file path |
199
+ | `/remote memory` | List the current server's memory entries and IDs |
198
200
  | `/remote config cwd PATH` | Persist its default remote working directory |
199
201
  | `/remote cd PATH` | Change the connected remote cwd and persist it |
200
202
  | `/remote config forward MAPPING...` | Persist port forwards such as `7860:127.0.0.1:7860` |
@@ -222,7 +224,7 @@ Endpoint configuration is stored locally in:
222
224
  ~/.pi/agent/ssh-remote-config.json
223
225
  ```
224
226
 
225
- Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Server-memory entries are stored separately under `~/.pi/agent/ssh-remote-memories/` as `{ "server", "entries": [{ "id", "content" }] }`; legacy string memories are migrated automatically. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Server memory is user-configured trusted context and is inserted into each model request only while a matching endpoint is the active remote workspace; it is not read from the remote server. The current server's exact memory path is the only local path that the routed `read`, `edit`, and `write` tools special-case. Private key contents, passwords, and key passphrases are **never written to these files**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
227
+ Saved global values include endpoints, active endpoint, notes, remote working directories, forwards, identity file paths, preview settings, and model-output budgets. Each Pi session also stores non-secret SSH workspace metadata so `/resume` can restore the server associated with that session. Private key contents, passwords, and key passphrases are **never written to this file**. Private keys are read locally only when connecting; prompted passwords and passphrases remain only in process memory.
226
228
 
227
229
  New or changed host keys require interactive confirmation and are stored separately from OpenSSH. By default, remote text reads return at most 400 lines or 16 KB, remote command results return the last 200 lines or 8 KB, and all remote tools in one agent turn share a 32 KB output budget. Text reads support `offset`/`limit` continuation without downloading the complete remote file. Oversized command output is streamed to a permission-restricted temporary local file rather than accumulated in memory. Configured limits may be raised only to the extension's hard safety ceilings. Preview-line settings affect only the collapsed UI and never increase model output.
228
230
 
@@ -232,13 +234,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
232
234
 
233
235
  ## Releases
234
236
 
235
- Latest release: [v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
237
+ Latest release: [v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
236
238
 
237
239
  | Version | Date | Highlights |
238
240
  |---|---|---|
241
+ | [0.1.13](CHANGELOG.md#0113---2026-09-18) | 2026-09-18 | Graceful working-directory fallback, longer SSH handshakes, and Windows path fixes |
242
+ | [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | Safe handling of repeated SSH errors when a connection is lost during handshake |
239
243
  | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
240
- | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | Safe inspect-by-default and append-only agent updates for server memory |
241
- | [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | Stable deduplicated server-memory context and shorter footer labels |
242
244
 
243
245
  See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
244
246
 
package/README.zh-CN.md CHANGED
@@ -54,7 +54,7 @@ H100 训练机 (root@gpu-box.example.com:2202):/srv/project
54
54
  - 服务器特定记忆;
55
55
  - 端口转发配置。
56
56
 
57
- 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。服务器记忆按 `user@host` 保存为 JSON 文件,每条记忆都是稳定的 `{ "id", "content" }` 对象;同一用户和主机即使通过不同 SSH 端口连接,也会使用同一组条目。插件会把精确的本地文件路径告诉 Pi,因此即使其他工具已路由到远端,Pi 仍能用普通 `read`、`edit`、`write` 工具对记忆条目增删查改。连接匹配的 endpoint 并启用远端工具路由后,有效条目会自动注入模型上下文;断开连接或切换到纯隧道模式后,后续请求不再注入。所有配置保存在本地,重启 Pi 后仍然存在。
57
+ 例如可以把几台机器分别备注为 `8xH100 训练机`、`预发布环境`、`线上只读机`。这些配置保存在本地,重启 Pi 后仍然存在。
58
58
 
59
59
  ### 断线后可以自动恢复
60
60
 
@@ -109,12 +109,6 @@ pi install npm:pi-ssh-remote
109
109
  /remote config note H100 训练机
110
110
  ```
111
111
 
112
- 然后直接告诉 Pi:
113
-
114
- ```text
115
- 添加一条服务器记忆:使用 /opt/conda/bin/python,不要停止其他用户的共享任务。
116
- ```
117
-
118
112
  查看当前状态:
119
113
 
120
114
  ```text
@@ -129,6 +123,29 @@ pi install npm:pi-ssh-remote
129
123
  /remote off
130
124
  ```
131
125
 
126
+ ### 服务器记忆
127
+
128
+ 直接告诉 Pi 要为当前服务器记住什么:
129
+
130
+ ```text
131
+ 记住这台服务器使用 /opt/conda/bin/python,不要停止共享任务。
132
+ ```
133
+
134
+ 查看已有记忆和 ID:
135
+
136
+ ```text
137
+ /remote memory
138
+ ```
139
+
140
+ 通过 ID 修改或删除:
141
+
142
+ ```text
143
+ 把记忆 deploy-command 改为使用 /opt/deploy/v2/run.sh。
144
+ 删除记忆 obsolete-proxy-rule。
145
+ ```
146
+
147
+ 以后连接这台服务器时,Pi 会自动使用这些记忆。只有明确要求删除时才会删除。
148
+
132
149
  ### 使用私钥文件登录
133
150
 
134
151
  通过 `-i` 指定本地私钥:
@@ -188,7 +205,7 @@ pi install npm:pi-ssh-remote
188
205
  /remote
189
206
  ```
190
207
 
191
- 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖;服务器记忆则按 `user@host` 保存为独立 JSON 文件并跨端口共享。
208
+ 备注和默认目录会按 `user@host:port` 分别保存,不会互相覆盖。
192
209
 
193
210
  ### 4. 远端启动模型,本地开发界面
194
211
 
@@ -223,25 +240,6 @@ pi install npm:pi-ssh-remote
223
240
  把远端 8000 端口转发到本地 8000,但代码工具继续留在本地。
224
241
  ```
225
242
 
226
- ```text
227
- 添加一条服务器记忆:部署必须使用 /opt/deploy/run.sh。
228
- ```
229
-
230
- 更新或删除前,可以先展示当前服务器的全部记忆和 ID:
231
-
232
- ```text
233
- /remote memory
234
- ```
235
-
236
- 然后使用展示出的 ID:
237
-
238
- ```text
239
- 把 ID 为 deploy-command 的记忆更新为使用 /opt/deploy/v2/run.sh。
240
- 删除 ID 为 obsolete-proxy-rule 的服务器记忆。
241
- ```
242
-
243
- Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action 只负责定位和查看匹配的 JSON 文件,实际增删查改使用 Pi 自带的 `read`、`edit`、`write`。删除规则会明确注入给 Agent:只有用户明确要求“删除/移除/忘记”服务器记忆时,Pi 才能先读取文件、确认准确条目 ID,再只删除该对象;目标不明确时必须询问,修改请求不等于删除授权,删除全部条目也必须由用户明确提出。
244
-
245
243
  ## 命令说明
246
244
 
247
245
  | 命令 | 作用 |
@@ -252,7 +250,7 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
252
250
  | `/remote use USER@HOST:PORT` | 切换到指定服务器 |
253
251
  | `/remote config note TEXT` | 给当前服务器添加或修改备注 |
254
252
  | `/remote config note --clear` | 清除当前服务器备注 |
255
- | `/remote memory` | 展示当前服务器的全部记忆条目、ID 和 JSON 文件路径 |
253
+ | `/remote memory` | 展示当前服务器的记忆条目和 ID |
256
254
  | `/remote config cwd PATH` | 设置默认远程工作目录 |
257
255
  | `/remote cd PATH` | 切换当前远程目录并保存 |
258
256
  | `/remote config forward MAPPING...` | 保存端口转发配置,例如 `7860:127.0.0.1:7860` |
@@ -291,15 +289,9 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
291
289
  - 命令预览设置;
292
290
  - 模型输出预算。
293
291
 
294
- 服务器记忆单独保存在:
295
-
296
- ```text
297
- ~/.pi/agent/ssh-remote-memories/
298
- ```
299
-
300
- 文件结构为 `{ "server", "entries": [{ "id", "content" }] }`,旧版字符串记忆会自动迁移为 JSON 条目。此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。服务器记忆按 `user@host` 识别,不受 SSH 端口影响。它是由用户在本地配置的可信上下文,不会从远程服务器自动读取;仅当匹配的 endpoint 作为远端工作区启用时才会加入每次模型请求。远端路由启用时,只有当前服务器对应的精确记忆文件路径会被 `read`、`edit`、`write` 特殊处理为本地文件。
292
+ 此外,每个 Pi session 都会记录不含凭据的 SSH 工作区元数据,用于在 `/resume` 时恢复该历史 session 对应的服务器环境。
301
293
 
302
- 私钥内容、密码和密钥 passphrase 都不会写入这些配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
294
+ 私钥内容、密码和密钥 passphrase 都不会写入配置文件。插件仅在连接时从本地读取私钥;手动输入的密码和 passphrase 只会缓存在当前 Pi 进程的内存中。
303
295
 
304
296
  ## 安全与输出限制
305
297
 
@@ -320,13 +312,13 @@ Pi 会通过插件提供的 `remote` 工具完成这些操作。`memory` action
320
312
 
321
313
  ## 版本发布
322
314
 
323
- 最新版本:[v0.1.11](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.11)
315
+ 最新版本:[v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
324
316
 
325
317
  | 版本 | 日期 | 主要内容 |
326
318
  |---|---|---|
319
+ | [0.1.13](CHANGELOG.md#0113---2026-09-18) | 2026-09-18 | 工作目录不可用时平滑回退、延长 SSH 握手等待并修复 Windows 路径 |
320
+ | [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | 安全处理 SSH 握手期间断线所触发的连续错误 |
327
321
  | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
328
- | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
329
- | [0.1.9](CHANGELOG.md#019---2026-08-12) | 2026-08-12 | 稳定且去重的服务器记忆上下文与更短的状态栏标签 |
330
322
 
331
323
  完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
332
324
 
package/index.ts CHANGED
@@ -10,12 +10,12 @@ import ssh2, { type Client as SshClient, type ClientChannel, type ConnectConfig,
10
10
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, statSync, writeFileSync, writeSync } from "node:fs";
11
11
  import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
12
12
  import { createServer, type Server, type Socket } from "node:net";
13
- import { tmpdir } from "node:os";
13
+ import { homedir, tmpdir } from "node:os";
14
14
  import { Type } from "typebox";
15
15
  import { StringEnum } from "@earendil-works/pi-ai";
16
16
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
17
  import {
18
- CONFIG_DIR_NAME,
18
+ getAgentDir,
19
19
  DEFAULT_MAX_BYTES,
20
20
  DEFAULT_MAX_LINES,
21
21
  createBashTool,
@@ -47,6 +47,7 @@ interface ParsedSsh {
47
47
  interface RemoteState extends ParsedSsh {
48
48
  client: SshClient;
49
49
  cwd: string;
50
+ unavailableCwd?: string;
50
51
  }
51
52
 
52
53
  interface CredentialCache {
@@ -106,7 +107,8 @@ interface SessionRemoteState {
106
107
  forwards?: string[];
107
108
  }
108
109
 
109
- const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
110
+ // Use Pi's agent dir so Windows follows USERPROFILE and PI_CODING_AGENT_DIR is honored.
111
+ const AGENT_DIR = getAgentDir();
110
112
  const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
111
113
  const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
112
114
  const SERVER_MEMORY_DIR = join(AGENT_DIR, "ssh-remote-memories");
@@ -120,6 +122,7 @@ const DEFAULT_EXEC_MAX_BYTES = 8 * 1024;
120
122
  const DEFAULT_TURN_MAX_BYTES = 32 * 1024;
121
123
  const MIN_MODEL_OUTPUT_BYTES = 1024;
122
124
  const OUTPUT_FOOTER_RESERVE_BYTES = 512;
125
+ const SSH_HANDSHAKE_TIMEOUT_MS = 30_000;
123
126
  const DEFAULT_REMOTE_TIMEOUT_SECONDS = 30;
124
127
  const MAX_REMOTE_TIMEOUT_SECONDS = 2_147_483_647 / 1000;
125
128
  const MAX_PRIVATE_KEY_BYTES = 1024 * 1024;
@@ -147,6 +150,10 @@ function shellWords(input: string): string[] {
147
150
  return words;
148
151
  }
149
152
 
153
+ function isHomeRelativePath(filePath: string): boolean {
154
+ return filePath === "~" || filePath.startsWith("~/");
155
+ }
156
+
150
157
  function parseSshCommand(command: string): ParsedSsh {
151
158
  const args = shellWords(command);
152
159
  if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
@@ -175,7 +182,7 @@ function parseSshCommand(command: string): ParsedSsh {
175
182
  else throw new Error("Unexpected extra argument in SSH command");
176
183
  }
177
184
  if (!target || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid SSH host or port");
178
- if (identityFile && identityFile !== "~" && !identityFile.startsWith("~/") && !isAbsolute(identityFile)) {
185
+ if (identityFile && !isHomeRelativePath(identityFile) && !isAbsolute(identityFile)) {
179
186
  throw new Error("SSH identity file must use an absolute path or ~/...");
180
187
  }
181
188
  const at = target.lastIndexOf("@");
@@ -207,10 +214,9 @@ function deleteCachedPassword(config: ParsedSsh): void {
207
214
 
208
215
  function resolveIdentityPath(config: ParsedSsh): string {
209
216
  if (!config.identityFile) throw new Error("No SSH identity file is configured");
210
- if (config.identityFile === "~" || config.identityFile.startsWith("~/")) {
211
- const home = process.env.HOME;
212
- if (!home) throw new Error("Cannot expand SSH identity path because HOME is not set");
213
- return config.identityFile === "~" ? home : join(home, config.identityFile.slice(2));
217
+ // Expand ~ with the user home directory, not Pi's agent directory.
218
+ if (isHomeRelativePath(config.identityFile)) {
219
+ return config.identityFile === "~" ? homedir() : join(homedir(), config.identityFile.slice(2));
214
220
  }
215
221
  return config.identityFile;
216
222
  }
@@ -690,7 +696,7 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
690
696
  let settled = false;
691
697
  const timer = setTimeout(() => {
692
698
  if (!settled) { settled = true; client.end(); reject(new Error("Connection timed out")); }
693
- }, 10000);
699
+ }, SSH_HANDSHAKE_TIMEOUT_MS + 2000);
694
700
  client.on("error", (error) => {
695
701
  if (!settled) { settled = true; clearTimeout(timer); reject(error); }
696
702
  });
@@ -698,7 +704,7 @@ function probeFingerprint(config: ParsedSsh): Promise<string> {
698
704
  host: config.host,
699
705
  port: config.port,
700
706
  username: config.username,
701
- readyTimeout: 8000,
707
+ readyTimeout: SSH_HANDSHAKE_TIMEOUT_MS,
702
708
  hostHash: "sha256",
703
709
  hostVerifier: (hash) => {
704
710
  if (!settled) { settled = true; clearTimeout(timer); resolve(hash); }
@@ -719,14 +725,18 @@ function connect(config: ParsedSsh, authentication: SshAuthentication, fingerpri
719
725
  port: config.port,
720
726
  username: config.username,
721
727
  ...authentication,
722
- readyTimeout: 12000,
728
+ readyTimeout: SSH_HANDSHAKE_TIMEOUT_MS,
723
729
  keepaliveInterval: 15000,
724
730
  keepaliveCountMax: 3,
725
731
  hostHash: "sha256",
726
732
  hostVerifier: (hash) => hash === fingerprint,
727
733
  };
728
734
  client.once("ready", () => resolve(client));
729
- client.once("error", reject);
735
+ // ssh2 may emit a socket error followed by a protocol error while a
736
+ // connection is lost during handshake. Keep consuming client errors after
737
+ // the first one so EventEmitter does not turn the follow-up into an
738
+ // uncaught exception; rejecting an already-settled promise is a no-op.
739
+ client.on("error", reject);
730
740
  client.connect(options);
731
741
  });
732
742
  }
@@ -1034,8 +1044,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1034
1044
  const client = await connect(parsed, authentication, fingerprint);
1035
1045
  try {
1036
1046
  const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
1037
- const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
1038
- const state = { ...parsed, client, cwd: resolved };
1047
+ let resolved: string;
1048
+ let unavailableCwd: string | undefined;
1049
+ try {
1050
+ resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
1051
+ } catch (error) {
1052
+ if (cwd === FALLBACK_REMOTE_CWD) throw error;
1053
+ unavailableCwd = cwd;
1054
+ resolved = (await execRemote(client, "cd -- ~ && pwd -P")).toString().trim();
1055
+ }
1056
+ const state = { ...parsed, client, cwd: resolved, ...(unavailableCwd ? { unavailableCwd } : {}) };
1039
1057
  attachClient(state);
1040
1058
  return state;
1041
1059
  } catch (error) {
@@ -1063,7 +1081,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1063
1081
  oldClient?.end();
1064
1082
  if (currentCtx) {
1065
1083
  status(currentCtx);
1066
- currentCtx.ui.notify(`SSH remote reconnected automatically: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
1084
+ if (next.unavailableCwd) {
1085
+ currentCtx.ui.notify(`SSH remote directory unavailable: ${next.unavailableCwd}; reconnected in default directory ${next.cwd}`, "warning");
1086
+ } else {
1087
+ currentCtx.ui.notify(`SSH remote reconnected automatically: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
1088
+ }
1067
1089
  }
1068
1090
  return next;
1069
1091
  })().finally(() => { reconnectPromise = null; });
@@ -1164,7 +1186,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1164
1186
  saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
1165
1187
  status(ctx);
1166
1188
  persistSessionRemoteState();
1167
- ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
1189
+ if (next.unavailableCwd) {
1190
+ ctx.ui.notify(`SSH remote directory unavailable: ${next.unavailableCwd}; connected in default directory ${next.cwd}`, "warning");
1191
+ } else {
1192
+ ctx.ui.notify(`SSH remote connected: ${endpointDisplayLabel(next)}:${next.cwd}`, "info");
1193
+ }
1168
1194
  return next;
1169
1195
  } catch (error) {
1170
1196
  if (!parsed.identityFile) deleteCachedPassword(parsed);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",