pi-ssh-remote 0.1.12 → 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,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.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
+
21
37
  ## [0.1.12] - 2026-08-25
22
38
 
23
39
  ### Added
@@ -220,7 +236,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel
220
236
 
221
237
  - None.
222
238
 
223
- [Unreleased]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.12...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
224
241
  [0.1.12]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.11...v0.1.12
225
242
  [0.1.11]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.10...v0.1.11
226
243
  [0.1.10]: https://github.com/petrichor20211/pi-ssh-remote/compare/v0.1.9...v0.1.10
package/README.md CHANGED
@@ -234,13 +234,13 @@ The extension currently supports direct SSH commands with `-p`, `-l`, and `-i`.
234
234
 
235
235
  ## Releases
236
236
 
237
- Latest release: [v0.1.12](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.12)
237
+ Latest release: [v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
238
238
 
239
239
  | Version | Date | Highlights |
240
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 |
241
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 |
242
243
  | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON memory entries, prompt-guided CRUD, and `/remote memory` listing |
243
- | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | Safe inspect-by-default and append-only agent updates for server memory |
244
244
 
245
245
  See [CHANGELOG.md](CHANGELOG.md) for the complete release history, including additions, behavior changes, and bug fixes.
246
246
 
package/README.zh-CN.md CHANGED
@@ -312,13 +312,13 @@ pi install npm:pi-ssh-remote
312
312
 
313
313
  ## 版本发布
314
314
 
315
- 最新版本:[v0.1.12](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.12)
315
+ 最新版本:[v0.1.13](https://github.com/petrichor20211/pi-ssh-remote/releases/tag/v0.1.13)
316
316
 
317
317
  | 版本 | 日期 | 主要内容 |
318
318
  |---|---|---|
319
+ | [0.1.13](CHANGELOG.md#0113---2026-09-18) | 2026-09-18 | 工作目录不可用时平滑回退、延长 SSH 握手等待并修复 Windows 路径 |
319
320
  | [0.1.12](CHANGELOG.md#0112---2026-08-25) | 2026-08-25 | 安全处理 SSH 握手期间断线所触发的连续错误 |
320
321
  | [0.1.11](CHANGELOG.md#0111---2026-08-16) | 2026-08-16 | JSON 记忆条目、Prompt 引导增删查改与 `/remote memory` 展示命令 |
321
- | [0.1.10](CHANGELOG.md#0110---2026-08-16) | 2026-08-16 | 默认安全读取且仅追加的服务器记忆 agent 更新逻辑 |
322
322
 
323
323
  完整的新增内容、行为变更和 Bug 修复记录请查看 [CHANGELOG.md](CHANGELOG.md)。
324
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,7 +725,7 @@ 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",
@@ -1038,8 +1044,16 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1038
1044
  const client = await connect(parsed, authentication, fingerprint);
1039
1045
  try {
1040
1046
  const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
1041
- const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
1042
- 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 } : {}) };
1043
1057
  attachClient(state);
1044
1058
  return state;
1045
1059
  } catch (error) {
@@ -1067,7 +1081,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1067
1081
  oldClient?.end();
1068
1082
  if (currentCtx) {
1069
1083
  status(currentCtx);
1070
- 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
+ }
1071
1089
  }
1072
1090
  return next;
1073
1091
  })().finally(() => { reconnectPromise = null; });
@@ -1168,7 +1186,11 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
1168
1186
  saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
1169
1187
  status(ctx);
1170
1188
  persistSessionRemoteState();
1171
- 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
+ }
1172
1194
  return next;
1173
1195
  } catch (error) {
1174
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.12",
3
+ "version": "0.1.13",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",