pi-ssh-remote 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yutong Bian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ <!--
2
+ Concise English documentation for pi-ssh-remote, covering its purpose, installation, essential commands, authentication, and security boundaries.
3
+ -->
4
+
5
+ # pi-ssh-remote
6
+
7
+ [中文](README.zh-CN.md)
8
+
9
+ Use Pi's file and shell tools on a persistent remote SSH workspace. Supports multiple endpoints, remote working directories, reconnection, and local port forwarding.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:pi-ssh-remote
15
+ ```
16
+
17
+ ## Use
18
+
19
+ ```text
20
+ /remote config ssh ssh USER@HOST -p PORT
21
+ /remote # connect
22
+ /remote cd /remote/project
23
+ /remote status
24
+ /remote off # return to local tools
25
+ ```
26
+
27
+ Run `/remote config` to list endpoints and `/remote` to see all available subcommands.
28
+
29
+ ## Authentication and security
30
+
31
+ Uses SSH agent authentication when available, otherwise prompts for a password. Passwords stay in process memory. New or changed host keys require confirmation and are stored separately from OpenSSH.
32
+
33
+ Only direct SSH commands with `-p` and `-l` are supported. `~/.ssh/config`, `IdentityFile`, and ProxyJump are not currently supported.
34
+
35
+ Requires Node.js 20+, Bash, SFTP, and GNU `timeout` on the remote host.
36
+
37
+ MIT licensed.
@@ -0,0 +1,37 @@
1
+ <!--
2
+ pi-ssh-remote 的简明中文文档,说明扩展用途、安装方式、必要命令、认证机制和安全边界。
3
+ -->
4
+
5
+ # pi-ssh-remote
6
+
7
+ [English](README.md)
8
+
9
+ 让 Pi 的文件与 Shell 工具在持久化 SSH 远程工作区中运行。支持多个服务器、远程工作目录、自动重连和本地端口转发。
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ pi install npm:pi-ssh-remote
15
+ ```
16
+
17
+ ## 使用
18
+
19
+ ```text
20
+ /remote config ssh ssh USER@HOST -p PORT
21
+ /remote # 连接
22
+ /remote cd /remote/project
23
+ /remote status
24
+ /remote off # 返回本地工具
25
+ ```
26
+
27
+ 使用 `/remote config` 查看服务器,使用 `/remote` 查看全部子命令。
28
+
29
+ ## 认证与安全
30
+
31
+ 优先使用 SSH agent,否则提示输入密码。密码仅缓存在进程内存中。首次连接或主机密钥变化时必须确认;主机密钥独立于 OpenSSH 存储。
32
+
33
+ 目前仅支持带 `-p` 和 `-l` 的直连 SSH 命令,暂不支持 `~/.ssh/config`、`IdentityFile` 和 ProxyJump。
34
+
35
+ 要求 Node.js 20+;远程服务器需提供 Bash、SFTP 和 GNU `timeout`。
36
+
37
+ 采用 MIT 许可证。
package/index.ts ADDED
@@ -0,0 +1,891 @@
1
+ /**
2
+ * Pi SSH Remote extension.
3
+ *
4
+ * Provides persistent SSH workspaces for Pi by routing file and shell tools to
5
+ * a verified remote host. It manages endpoint configuration, in-memory
6
+ * credentials, remote working directories, reconnection, and TCP forwarding.
7
+ */
8
+
9
+ import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
10
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join, relative, sep } from "node:path";
12
+ import { createServer, type Server, type Socket } from "node:net";
13
+ import { tmpdir } from "node:os";
14
+ import { Type } from "typebox";
15
+ import { StringEnum } from "@earendil-works/pi-ai";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import {
18
+ CONFIG_DIR_NAME,
19
+ DEFAULT_MAX_BYTES,
20
+ DEFAULT_MAX_LINES,
21
+ createBashTool,
22
+ createEditTool,
23
+ createReadTool,
24
+ createWriteTool,
25
+ formatSize,
26
+ truncateTail,
27
+ type BashOperations,
28
+ type EditOperations,
29
+ type ReadOperations,
30
+ type WriteOperations,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
33
+
34
+ interface ParsedSsh {
35
+ host: string;
36
+ port: number;
37
+ username: string;
38
+ label: string;
39
+ command: string;
40
+ }
41
+
42
+ interface RemoteState extends ParsedSsh {
43
+ client: Client;
44
+ cwd: string;
45
+ }
46
+
47
+ interface CredentialCache {
48
+ passwords: Map<string, string>;
49
+ resume?: { command: string; cwd: string };
50
+ }
51
+
52
+ interface RemoteEndpointConfig {
53
+ sshCommand?: string;
54
+ remoteCwd?: string;
55
+ forwards?: string[];
56
+ }
57
+
58
+ interface RemoteConfig {
59
+ activeEndpoint?: string;
60
+ endpoints?: Record<string, RemoteEndpointConfig>;
61
+ /** Legacy fields migrated into endpoints on the next config write. */
62
+ sshCommand?: string;
63
+ remoteCwd?: string;
64
+ forwards?: string[];
65
+ }
66
+
67
+ interface ForwardSpec {
68
+ localPort: number;
69
+ remoteHost: string;
70
+ remotePort: number;
71
+ }
72
+
73
+ const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
74
+ const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
75
+ const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
76
+ const FALLBACK_REMOTE_CWD = "~";
77
+ const CACHE_KEY = "__piHpcCredentialCacheV1";
78
+ const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
79
+ const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
80
+
81
+ function shellWords(input: string): string[] {
82
+ const words: string[] = [];
83
+ let word = "";
84
+ let quoteChar: "'" | '"' | null = null;
85
+ let escaped = false;
86
+ for (const ch of input.trim()) {
87
+ if (escaped) { word += ch; escaped = false; continue; }
88
+ if (ch === "\\" && quoteChar !== "'") { escaped = true; continue; }
89
+ if (quoteChar) { if (ch === quoteChar) quoteChar = null; else word += ch; continue; }
90
+ if (ch === "'" || ch === '"') { quoteChar = ch; continue; }
91
+ if (/\s/.test(ch)) { if (word) { words.push(word); word = ""; } }
92
+ else word += ch;
93
+ }
94
+ if (escaped || quoteChar) throw new Error("Incomplete quoting or escaping in SSH command");
95
+ if (word) words.push(word);
96
+ return words;
97
+ }
98
+
99
+ function parseSshCommand(command: string): ParsedSsh {
100
+ const args = shellWords(command);
101
+ if (args[0] !== "ssh") throw new Error("Command must start with ssh, for example: ssh root@host -p 22");
102
+ let port = 22;
103
+ let username = process.env.USER || "root";
104
+ let target: string | undefined;
105
+ for (let i = 1; i < args.length; i++) {
106
+ const arg = args[i]!;
107
+ if (arg === "-p") { port = Number(args[++i]); continue; }
108
+ if (arg.startsWith("-p") && arg.length > 2) { port = Number(arg.slice(2)); continue; }
109
+ if (arg === "-l") { username = args[++i] || username; continue; }
110
+ if (arg.startsWith("-")) throw new Error(`Unsupported SSH option ${arg}; only -p and -l are currently supported`);
111
+ if (!target) target = arg;
112
+ else throw new Error("Unexpected extra argument in SSH command");
113
+ }
114
+ if (!target || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid SSH host or port");
115
+ const at = target.lastIndexOf("@");
116
+ const host = at >= 0 ? target.slice(at + 1) : target;
117
+ if (at >= 0) username = target.slice(0, at);
118
+ if (!host || !username) throw new Error("Invalid SSH username or host");
119
+ return { host, port, username, label: `${username}@${host}:${port}`, command };
120
+ }
121
+
122
+ function cacheId(config: ParsedSsh): string {
123
+ return `${config.username}@${config.host}:${config.port}`;
124
+ }
125
+
126
+ function getCachedPassword(config: ParsedSsh): string | undefined {
127
+ return credentialCache.passwords.get(cacheId(config));
128
+ }
129
+
130
+ function setCachedPassword(config: ParsedSsh, password: string): void {
131
+ credentialCache.passwords.set(cacheId(config), password);
132
+ }
133
+
134
+ function deleteCachedPassword(config: ParsedSsh): void {
135
+ credentialCache.passwords.delete(cacheId(config));
136
+ }
137
+
138
+ function parseForwardSpec(value: string): ForwardSpec {
139
+ const match = value.match(/^(\d+):([^:]+):(\d+)$/);
140
+ if (!match) throw new Error(`Invalid port-forward specification: ${value}; expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT`);
141
+ const localPort = Number(match[1]);
142
+ const remoteHost = match[2]!;
143
+ const remotePort = Number(match[3]);
144
+ if (![localPort, remotePort].every((port) => Number.isInteger(port) && port > 0 && port <= 65535)) {
145
+ throw new Error(`Port out of range in forwarding specification: ${value}`);
146
+ }
147
+ return { localPort, remoteHost, remotePort };
148
+ }
149
+
150
+ function quote(value: string): string {
151
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
152
+ }
153
+
154
+ function commandFromEndpointKey(key: string): string | undefined {
155
+ const match = key.match(/^([^@]+)@(.+):(\d+)$/);
156
+ if (!match) return undefined;
157
+ const [, username, host, port] = match;
158
+ return `ssh ${username}@${host} -p ${port}`;
159
+ }
160
+
161
+ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
162
+ const endpoints = { ...(config.endpoints ?? {}) };
163
+ for (const [key, endpoint] of Object.entries(endpoints)) {
164
+ endpoints[key] = {
165
+ ...endpoint,
166
+ ...(endpoint.sshCommand ? {} : { sshCommand: commandFromEndpointKey(key) }),
167
+ };
168
+ }
169
+
170
+ let activeEndpoint = config.activeEndpoint;
171
+ if (config.sshCommand) {
172
+ try {
173
+ const key = cacheId(parseSshCommand(config.sshCommand));
174
+ endpoints[key] = {
175
+ ...(endpoints[key] ?? {}),
176
+ sshCommand: config.sshCommand,
177
+ ...(config.remoteCwd !== undefined ? { remoteCwd: config.remoteCwd } : {}),
178
+ ...(config.forwards !== undefined ? { forwards: config.forwards } : {}),
179
+ };
180
+ activeEndpoint ??= key;
181
+ } catch {}
182
+ }
183
+ if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
184
+ activeEndpoint ??= Object.keys(endpoints)[0];
185
+
186
+ return {
187
+ ...(activeEndpoint ? { activeEndpoint } : {}),
188
+ ...(Object.keys(endpoints).length ? { endpoints } : {}),
189
+ };
190
+ }
191
+
192
+ function loadRemoteConfig(): RemoteConfig {
193
+ try { return normalizeRemoteConfig(JSON.parse(readFileSync(REMOTE_CONFIG_FILE, "utf8"))); }
194
+ catch { return {}; }
195
+ }
196
+
197
+ function saveRemoteConfig(config: RemoteConfig): void {
198
+ mkdirSync(dirname(REMOTE_CONFIG_FILE), { recursive: true });
199
+ writeFileSync(REMOTE_CONFIG_FILE, JSON.stringify(normalizeRemoteConfig(config), null, 2) + "\n", { mode: 0o600 });
200
+ }
201
+
202
+ function endpointConfig(config: RemoteConfig, command: string): RemoteEndpointConfig {
203
+ try { return config.endpoints?.[cacheId(parseSshCommand(command))] ?? {}; }
204
+ catch { return {}; }
205
+ }
206
+
207
+ function activeEndpointConfig(config: RemoteConfig): RemoteEndpointConfig | undefined {
208
+ return config.activeEndpoint ? config.endpoints?.[config.activeEndpoint] : undefined;
209
+ }
210
+
211
+ function activeSshCommand(config = loadRemoteConfig()): string | undefined {
212
+ return activeEndpointConfig(config)?.sshCommand;
213
+ }
214
+
215
+ function saveEndpointConfig(command: string, updates: RemoteEndpointConfig, makeActive = false): void {
216
+ const config = loadRemoteConfig();
217
+ const key = cacheId(parseSshCommand(command));
218
+ saveRemoteConfig({
219
+ ...config,
220
+ ...(makeActive ? { activeEndpoint: key } : {}),
221
+ endpoints: {
222
+ ...(config.endpoints ?? {}),
223
+ [key]: { ...endpointConfig(config, command), sshCommand: command, ...updates },
224
+ },
225
+ });
226
+ }
227
+
228
+ function loadKnownHosts(): Record<string, string> {
229
+ try { return JSON.parse(readFileSync(KNOWN_HOSTS_FILE, "utf8")); }
230
+ catch { return {}; }
231
+ }
232
+
233
+ function saveKnownHost(key: string, fingerprint: string): void {
234
+ const hosts = loadKnownHosts();
235
+ hosts[key] = fingerprint;
236
+ mkdirSync(dirname(KNOWN_HOSTS_FILE), { recursive: true });
237
+ writeFileSync(KNOWN_HOSTS_FILE, JSON.stringify(hosts, null, 2) + "\n", { mode: 0o600 });
238
+ }
239
+
240
+ function displayFingerprint(hex: string): string {
241
+ return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
242
+ }
243
+
244
+ function formatRemoteOutput(output: string): { text: string; fullOutputPath?: string } {
245
+ const truncated = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
246
+ if (!truncated.truncated) return { text: truncated.content || "Remote command completed." };
247
+
248
+ const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
249
+ const fullOutputPath = join(outputDir, "output.log");
250
+ writeFileSync(fullOutputPath, output, { encoding: "utf8", mode: 0o600 });
251
+ const text = `${truncated.content}\n\n[Output truncated: ${truncated.outputLines} of ${truncated.totalLines} lines (${formatSize(truncated.outputBytes)} of ${formatSize(truncated.totalBytes)}). Full output saved locally to: ${fullOutputPath}]`;
252
+ return { text, fullOutputPath };
253
+ }
254
+
255
+ function probeFingerprint(config: ParsedSsh): Promise<string> {
256
+ return new Promise((resolve, reject) => {
257
+ const client = new Client();
258
+ let settled = false;
259
+ const timer = setTimeout(() => {
260
+ if (!settled) { settled = true; client.end(); reject(new Error("Connection timed out")); }
261
+ }, 10000);
262
+ client.on("error", (error) => {
263
+ if (!settled) { settled = true; clearTimeout(timer); reject(error); }
264
+ });
265
+ client.connect({
266
+ host: config.host,
267
+ port: config.port,
268
+ username: config.username,
269
+ readyTimeout: 8000,
270
+ hostHash: "sha256",
271
+ hostVerifier: (hash) => {
272
+ if (!settled) { settled = true; clearTimeout(timer); resolve(hash); }
273
+ setImmediate(() => client.end());
274
+ return false;
275
+ },
276
+ });
277
+ });
278
+ }
279
+
280
+ function connect(config: ParsedSsh, password: string | undefined, fingerprint: string): Promise<Client> {
281
+ return new Promise((resolve, reject) => {
282
+ const client = new Client();
283
+ const options: ConnectConfig = {
284
+ host: config.host,
285
+ port: config.port,
286
+ username: config.username,
287
+ ...(password ? { password } : {}),
288
+ ...(process.env.SSH_AUTH_SOCK ? { agent: process.env.SSH_AUTH_SOCK } : {}),
289
+ readyTimeout: 12000,
290
+ keepaliveInterval: 15000,
291
+ keepaliveCountMax: 3,
292
+ hostHash: "sha256",
293
+ hostVerifier: (hash) => hash === fingerprint,
294
+ };
295
+ client.once("ready", () => resolve(client));
296
+ client.once("error", reject);
297
+ client.connect(options);
298
+ });
299
+ }
300
+
301
+ function execRemote(client: Client, command: string, allowFailure = false): Promise<Buffer> {
302
+ return new Promise((resolve, reject) => {
303
+ client.exec(command, (error, stream) => {
304
+ if (error) return reject(error);
305
+ const stdout: Buffer[] = [];
306
+ const stderr: Buffer[] = [];
307
+ stream.on("data", (chunk: Buffer) => stdout.push(chunk));
308
+ stream.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
309
+ stream.on("close", (code: number | null) => {
310
+ if (!allowFailure && code !== 0) reject(new Error(Buffer.concat(stderr).toString().trim() || `Remote command exited with code ${code}`));
311
+ else resolve(Buffer.concat(stdout));
312
+ });
313
+ });
314
+ });
315
+ }
316
+
317
+ function getSftp(client: Client): Promise<SFTPWrapper> {
318
+ return new Promise((resolve, reject) => client.sftp((error, sftp) => error ? reject(error) : resolve(sftp)));
319
+ }
320
+
321
+ async function withSftp<T>(client: Client, operation: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
322
+ const sftp = await getSftp(client);
323
+ try { return await operation(sftp); }
324
+ finally { sftp.end(); }
325
+ }
326
+
327
+ function isReconnectable(error: unknown): boolean {
328
+ const message = error instanceof Error ? error.message : String(error);
329
+ 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);
330
+ }
331
+
332
+ class PasswordInput implements Component, Focusable {
333
+ focused = false;
334
+ private value = "";
335
+ constructor(private done: (value: string | null) => void, private renderNow: () => void) {}
336
+ handleInput(data: string): void {
337
+ if (matchesKey(data, Key.enter)) return this.done(this.value);
338
+ if (matchesKey(data, Key.escape)) return this.done(null);
339
+ if (matchesKey(data, Key.backspace)) this.value = [...this.value].slice(0, -1).join("");
340
+ else if (matchesKey(data, Key.ctrl("u"))) this.value = "";
341
+ else {
342
+ const clean = data.replace(/\x1b\[200~/g, "").replace(/\x1b\[201~/g, "");
343
+ if ([...clean].every((ch) => ch.charCodeAt(0) >= 32 && ch.charCodeAt(0) !== 127)) this.value += clean;
344
+ }
345
+ this.renderNow();
346
+ }
347
+ render(width: number): string[] {
348
+ return [truncateToWidth(`SSH password: ${"•".repeat([...this.value].length)}${this.focused ? CURSOR_MARKER : ""}\x1b[7m \x1b[27m`, width, "")];
349
+ }
350
+ invalidate(): void {}
351
+ }
352
+
353
+ async function askPassword(ctx: any): Promise<string | null> {
354
+ if (ctx.mode !== "tui") return (await ctx.ui.input("SSH password:", "password")) ?? null;
355
+ return ctx.ui.custom<string | null>((tui: any, _theme: any, _keys: any, done: (value: string | null) => void) =>
356
+ new PasswordInput(done, () => tui.requestRender()));
357
+ }
358
+
359
+ export default function sshRemoteExtension(pi: ExtensionAPI) {
360
+ const localCwd = process.cwd();
361
+ let remote: RemoteState | null = null;
362
+ let routeRemoteTools = false;
363
+ let currentCtx: any;
364
+ let reconnectPromise: Promise<RemoteState> | null = null;
365
+ const forwardServers = new Map<number, Server>();
366
+ let lastConnectionError: string | undefined;
367
+ let lastCommand = credentialCache.resume?.command ?? activeSshCommand() ?? "";
368
+
369
+ const configuredCwd = (command: string): string =>
370
+ endpointConfig(loadRemoteConfig(), command).remoteCwd || FALLBACK_REMOTE_CWD;
371
+
372
+ const configuredForwards = (command: string): string[] =>
373
+ endpointConfig(loadRemoteConfig(), command).forwards ?? [];
374
+
375
+ const mapPath = (path: string): string => {
376
+ if (!remote) return path;
377
+ if (path === localCwd) return remote.cwd;
378
+ const prefix = localCwd.endsWith(sep) ? localCwd : localCwd + sep;
379
+ if (path.startsWith(prefix)) return remote.cwd.replace(/\/$/, "") + "/" + relative(localCwd, path).split(sep).join("/");
380
+ return path;
381
+ };
382
+
383
+ const status = (ctx: any) => {
384
+ currentCtx = ctx;
385
+ if (!remote) ctx.ui.setStatus("ssh-remote", undefined);
386
+ else if (routeRemoteTools) ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote ${remote.label}:${remote.cwd}`));
387
+ else ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("accent", `SSH remote tunnel ${[...forwardServers.keys()].join(",") || remote.label}`));
388
+ };
389
+
390
+ const attachClient = (state: RemoteState) => {
391
+ const { client } = state;
392
+ client.on("close", () => {
393
+ if (remote?.client !== client) return;
394
+ if (currentCtx) {
395
+ currentCtx.ui.setStatus("ssh-remote", currentCtx.ui.theme.fg("warning", `SSH remote reconnecting ${state.label}…`));
396
+ }
397
+ void reconnectRemote().catch((error) => {
398
+ if (currentCtx) currentCtx.ui.notify(`SSH remote automatic reconnection failed: ${(error as Error).message}`, "error");
399
+ });
400
+ });
401
+ };
402
+
403
+ const establish = async (parsed: ParsedSsh, password: string | undefined, cwd: string): Promise<RemoteState> => {
404
+ const key = `${parsed.host}:${parsed.port}`;
405
+ const fingerprint = loadKnownHosts()[key];
406
+ if (!fingerprint) throw new Error(`Host ${key} is not trusted; connect interactively with /remote first`);
407
+ const client = await connect(parsed, password, fingerprint);
408
+ try {
409
+ const cdCommand = cwd === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(cwd)}`;
410
+ const resolved = (await execRemote(client, `${cdCommand} && pwd -P`)).toString().trim();
411
+ const state = { ...parsed, client, cwd: resolved };
412
+ attachClient(state);
413
+ return state;
414
+ } catch (error) {
415
+ client.end();
416
+ throw error;
417
+ }
418
+ };
419
+
420
+ async function reconnectRemote(): Promise<RemoteState> {
421
+ if (reconnectPromise) return reconnectPromise;
422
+ const source = remote ?? (credentialCache.resume ? { ...parseSshCommand(credentialCache.resume.command), cwd: credentialCache.resume.cwd } : null);
423
+ if (!source) throw new Error("No SSH remote connection is available to reconnect");
424
+ const parsed = parseSshCommand(source.command);
425
+ const password = getCachedPassword(parsed);
426
+ reconnectPromise = (async () => {
427
+ const oldClient = remote?.client;
428
+ const next = await establish(parsed, password, source.cwd);
429
+ remote = next;
430
+ credentialCache.resume = { command: parsed.command, cwd: next.cwd };
431
+ oldClient?.end();
432
+ if (currentCtx) {
433
+ status(currentCtx);
434
+ currentCtx.ui.notify(`SSH remote reconnected automatically: ${next.label}:${next.cwd}`, "info");
435
+ }
436
+ return next;
437
+ })().finally(() => { reconnectPromise = null; });
438
+ return reconnectPromise;
439
+ }
440
+
441
+ const withReconnect = async <T>(operation: (client: Client) => Promise<T>): Promise<T> => {
442
+ if (!remote) throw new Error("SSH remote is not connected");
443
+ try { return await operation(remote.client); }
444
+ catch (error) {
445
+ if (!isReconnectable(error)) throw error;
446
+ const state = await reconnectRemote();
447
+ return operation(state.client);
448
+ }
449
+ };
450
+
451
+ const changeRemoteCwd = async (requested: string, ctx: any): Promise<string> => {
452
+ if (!remote) throw new Error("SSH remote is not connected");
453
+ const target = requested.trim() || FALLBACK_REMOTE_CWD;
454
+ const targetCommand = target === FALLBACK_REMOTE_CWD ? "cd -- ~" : `cd -- ${quote(target)}`;
455
+ const resolved = (await withReconnect((client) => execRemote(
456
+ client,
457
+ `cd -- ${quote(remote!.cwd)} && ${targetCommand} && pwd -P`,
458
+ ))).toString().trim();
459
+ remote.cwd = resolved;
460
+ credentialCache.resume = { command: remote.command, cwd: resolved };
461
+ saveEndpointConfig(remote.command, { remoteCwd: resolved }, true);
462
+ status(ctx);
463
+ return resolved;
464
+ };
465
+
466
+ const standaloneCdTarget = (command: string): string | undefined => {
467
+ try {
468
+ const words = shellWords(command);
469
+ if (words[0] !== "cd" || words.length > 3) return undefined;
470
+ if (words.length === 3 && words[1] !== "--") return undefined;
471
+ return words.at(-1) === "cd" || words.at(-1) === "--" ? FALLBACK_REMOTE_CWD : words.at(-1);
472
+ } catch {
473
+ return undefined;
474
+ }
475
+ };
476
+
477
+ const connectInteractive = async (command: string, ctx: any, cwd?: string): Promise<RemoteState | null> => {
478
+ let parsed: ParsedSsh;
479
+ try { parsed = parseSshCommand(command); }
480
+ catch (error) {
481
+ lastConnectionError = (error as Error).message;
482
+ ctx.ui.notify(lastConnectionError, "error");
483
+ return null;
484
+ }
485
+ lastConnectionError = undefined;
486
+
487
+ const key = `${parsed.host}:${parsed.port}`;
488
+ const savedFingerprint = loadKnownHosts()[key];
489
+ const fingerprint = await probeFingerprint(parsed);
490
+ if (!savedFingerprint) {
491
+ const trusted = await ctx.ui.confirm("Trust SSH host", `${parsed.label}\nHost key: ${displayFingerprint(fingerprint)}\nTrust and save this key?`);
492
+ if (!trusted) { lastConnectionError = "The host key was not trusted"; return null; }
493
+ saveKnownHost(key, fingerprint);
494
+ } else if (savedFingerprint !== fingerprint) {
495
+ const trusted = await ctx.ui.confirm(
496
+ "SSH host key changed",
497
+ `${parsed.label}\nPrevious key: ${displayFingerprint(savedFingerprint)}\nNew key: ${displayFingerprint(fingerprint)}\nVerify the server identity. Update the saved key and continue?`,
498
+ );
499
+ if (!trusted) { lastConnectionError = "The changed host key was rejected"; return null; }
500
+ saveKnownHost(key, fingerprint);
501
+ }
502
+
503
+ let password = getCachedPassword(parsed);
504
+ ctx.ui.setStatus("ssh-remote", ctx.ui.theme.fg("warning", `SSH remote connecting ${parsed.label}…`));
505
+ try {
506
+ let next: RemoteState;
507
+ try {
508
+ next = await establish(parsed, password, cwd ?? configuredCwd(command));
509
+ } catch (error) {
510
+ if (!/authentication methods failed|authentication failure/i.test((error as Error).message)) throw error;
511
+ password = await askPassword(ctx) ?? undefined;
512
+ if (!password) {
513
+ lastConnectionError = "No SSH password was provided and SSH agent authentication failed";
514
+ status(ctx);
515
+ return null;
516
+ }
517
+ next = await establish(parsed, password, cwd ?? configuredCwd(command));
518
+ }
519
+ const previous = remote?.client;
520
+ remote = next;
521
+ routeRemoteTools = true;
522
+ previous?.end();
523
+ if (password) setCachedPassword(parsed, password);
524
+ credentialCache.resume = { command, cwd: next.cwd };
525
+ lastCommand = command;
526
+ lastConnectionError = undefined;
527
+ saveEndpointConfig(command, { remoteCwd: next.cwd }, true);
528
+ status(ctx);
529
+ ctx.ui.notify(`SSH remote connected: ${parsed.label}:${next.cwd}`, "info");
530
+ return next;
531
+ } catch (error) {
532
+ deleteCachedPassword(parsed);
533
+ remote = null;
534
+ lastConnectionError = (error as Error).message;
535
+ status(ctx);
536
+ ctx.ui.notify(`SSH remote connection failed: ${lastConnectionError}`, "error");
537
+ return null;
538
+ }
539
+ };
540
+
541
+ const ensureConnected = async (ctx: any): Promise<RemoteState> => {
542
+ if (remote) return remote;
543
+ if (credentialCache.resume) return reconnectRemote();
544
+ const command = lastCommand || activeSshCommand();
545
+ if (!command) throw new Error("No SSH endpoint configured; use /remote config ssh ssh USER@HOST -p PORT");
546
+ const state = await connectInteractive(command, ctx, configuredCwd(command));
547
+ if (!state) throw new Error("SSH remote connection was cancelled or failed");
548
+ return state;
549
+ };
550
+
551
+ const startForward = async (spec: ForwardSpec): Promise<void> => {
552
+ if (forwardServers.has(spec.localPort)) return;
553
+ const server = createServer((socket: Socket) => {
554
+ void withReconnect((client) => new Promise<ClientChannel>((resolve, reject) =>
555
+ client.forwardOut("127.0.0.1", 0, spec.remoteHost, spec.remotePort, (error, stream) =>
556
+ error ? reject(error) : resolve(stream)))).then((stream) => {
557
+ socket.on("error", () => stream.close());
558
+ stream.on("error", () => socket.destroy());
559
+ socket.pipe(stream).pipe(socket);
560
+ }, () => socket.destroy());
561
+ });
562
+ await new Promise<void>((resolve, reject) => {
563
+ const onError = (error: Error) => { server.close(); reject(error); };
564
+ server.once("error", onError);
565
+ server.listen(spec.localPort, "127.0.0.1", () => {
566
+ server.off("error", onError);
567
+ server.on("error", () => {});
568
+ resolve();
569
+ });
570
+ });
571
+ forwardServers.set(spec.localPort, server);
572
+ };
573
+
574
+ const stopForwards = async (): Promise<void> => {
575
+ const servers = [...forwardServers.values()];
576
+ forwardServers.clear();
577
+ await Promise.all(servers.map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
578
+ };
579
+
580
+ const disconnect = (ctx: any, forgetPassword = false) => {
581
+ const previous = remote;
582
+ remote = null;
583
+ routeRemoteTools = false;
584
+ reconnectPromise = null;
585
+ credentialCache.resume = undefined;
586
+ void stopForwards();
587
+ if (forgetPassword) {
588
+ if (previous) deleteCachedPassword(previous);
589
+ else {
590
+ const configured = activeSshCommand();
591
+ if (configured) {
592
+ try { deleteCachedPassword(parseSshCommand(configured)); } catch {}
593
+ }
594
+ }
595
+ }
596
+ previous?.client.end();
597
+ status(ctx);
598
+ ctx.ui.notify(forgetPassword ? "SSH remote disconnected and cached password cleared" : "SSH remote mode disabled (password remains cached in memory only)", "info");
599
+ };
600
+
601
+ const remoteReadOps = (): ReadOperations => ({
602
+ readFile: (path) => withReconnect((client) => withSftp(client, (sftp) =>
603
+ new Promise<Buffer>((resolve, reject) => sftp.readFile(mapPath(path), (error, data) => error ? reject(error) : resolve(data))))),
604
+ access: (path) => withReconnect((client) => withSftp(client, (sftp) =>
605
+ new Promise<void>((resolve, reject) => sftp.stat(mapPath(path), (error) => error ? reject(error) : resolve())))),
606
+ detectImageMimeType: async (path) => {
607
+ try {
608
+ const mime = (await withReconnect((client) => execRemote(client, `file --mime-type -b -- ${quote(mapPath(path))}`))).toString().trim();
609
+ return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(mime) ? mime : null;
610
+ } catch { return null; }
611
+ },
612
+ });
613
+
614
+ const remoteWriteOps = (): WriteOperations => ({
615
+ writeFile: (path, content) => withReconnect((client) => withSftp(client, (sftp) =>
616
+ new Promise<void>((resolve, reject) => sftp.writeFile(mapPath(path), content, (error) => error ? reject(error) : resolve())))),
617
+ mkdir: async (path) => { await withReconnect((client) => execRemote(client, `mkdir -p -- ${quote(mapPath(path))}`)); },
618
+ });
619
+
620
+ const remoteEditOps = (): EditOperations => {
621
+ const read = remoteReadOps();
622
+ const write = remoteWriteOps();
623
+ return { readFile: read.readFile, access: read.access, writeFile: write.writeFile };
624
+ };
625
+
626
+ const openExecChannel = async (command: string): Promise<ClientChannel> =>
627
+ withReconnect((client) => new Promise<ClientChannel>((resolve, reject) =>
628
+ client.exec(command, (error, stream) => error ? reject(error) : resolve(stream))));
629
+
630
+ const remoteBashOps = (): BashOperations => ({
631
+ exec: (command, cwd, { onData, signal, timeout }) => new Promise((resolve, reject) => {
632
+ const timeoutSeconds = timeout ? Math.max(1, Math.ceil(timeout)) : undefined;
633
+ const remoteCommand = timeoutSeconds
634
+ ? `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -lc ${quote(command)}`
635
+ : command;
636
+ const full = `cd -- ${quote(mapPath(cwd))} && ${remoteCommand}`;
637
+ void openExecChannel(full).then((stream) => {
638
+ let timedOut = false;
639
+ const timer = timeoutSeconds ? setTimeout(() => { timedOut = true; stream.close(); }, (timeoutSeconds + 8) * 1000) : undefined;
640
+ const abort = () => stream.close();
641
+ signal?.addEventListener("abort", abort, { once: true });
642
+ stream.on("data", onData);
643
+ stream.stderr.on("data", onData);
644
+ stream.on("close", (code: number | null) => {
645
+ if (timer) clearTimeout(timer);
646
+ signal?.removeEventListener("abort", abort);
647
+ if (signal?.aborted) reject(new Error("aborted"));
648
+ else if (timedOut || code === 124) reject(new Error(`timeout:${timeoutSeconds}`));
649
+ else resolve({ exitCode: code });
650
+ });
651
+ }, reject);
652
+ }),
653
+ });
654
+
655
+ const localRead = createReadTool(localCwd);
656
+ const localWrite = createWriteTool(localCwd);
657
+ const localEdit = createEditTool(localCwd);
658
+ const localBash = createBashTool(localCwd);
659
+ pi.registerTool({ ...localRead, execute: (id, params, signal, update) => remote && routeRemoteTools ? createReadTool(localCwd, { operations: remoteReadOps() }).execute(id, params, signal, update) : localRead.execute(id, params, signal, update) });
660
+ pi.registerTool({ ...localWrite, execute: (id, params, signal, update) => remote && routeRemoteTools ? createWriteTool(localCwd, { operations: remoteWriteOps() }).execute(id, params, signal, update) : localWrite.execute(id, params, signal, update) });
661
+ pi.registerTool({ ...localEdit, execute: (id, params, signal, update) => remote && routeRemoteTools ? createEditTool(localCwd, { operations: remoteEditOps() }).execute(id, params, signal, update) : localEdit.execute(id, params, signal, update) });
662
+ pi.registerTool({ ...localBash, execute: (id, params, signal, update) => remote && routeRemoteTools ? createBashTool(localCwd, { operations: remoteBashOps() }).execute(id, params, signal, update) : localBash.execute(id, params, signal, update) });
663
+
664
+ pi.registerTool({
665
+ name: "ssh_remote_control",
666
+ label: "SSH Remote Control",
667
+ description: "Connect, reconnect, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Passwords are never accepted as arguments and are cached only in process memory. Command output is limited to 50KB or 2000 lines; truncated output is saved to a local temporary file.",
668
+ promptSnippet: "Control the configured remote SSH connection, working directory, and local port forwarding",
669
+ promptGuidelines: [
670
+ "Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
671
+ "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.",
672
+ "Use ssh_remote_control with action disconnect after remote work when the user asks to return to the local environment.",
673
+ ],
674
+ parameters: Type.Object({
675
+ action: StringEnum(["connect", "reconnect", "status", "disconnect", "forget", "forward", "unforward", "exec", "chdir"] as const),
676
+ command: Type.Optional(Type.String({ description: "SSH command for connect, such as ssh root@host -p 22" })),
677
+ cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
678
+ forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
679
+ remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
680
+ }),
681
+ async execute(_id, params, _signal, _update, ctx) {
682
+ if (params.action === "status") {
683
+ const mappings = [...forwardServers.keys()].sort((a, b) => a - b);
684
+ const text = `${remote ? `Connected: ${remote.label}:${remote.cwd}; tool routing: ${routeRemoteTools ? "remote" : "local"}` : "SSH remote is disconnected"}${mappings.length ? `; forwarded local ports: ${mappings.join(", ")}` : ""}`;
685
+ return { content: [{ type: "text", text }], details: { connected: Boolean(remote), cwd: remote?.cwd, toolRouting: routeRemoteTools ? "remote" : "local", forwardedPorts: mappings } };
686
+ }
687
+ if (params.action === "disconnect" || params.action === "forget") {
688
+ disconnect(ctx, params.action === "forget");
689
+ 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 } };
690
+ }
691
+ if (params.action === "reconnect") {
692
+ if (!remote && !credentialCache.resume) throw new Error("No SSH remote connection is available to reconnect");
693
+ const state = await reconnectRemote();
694
+ return { content: [{ type: "text", text: `Reconnected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
695
+ }
696
+ if (params.action === "unforward") {
697
+ await stopForwards();
698
+ return { content: [{ type: "text", text: "Closed all extension-managed SSH port forwards." }], details: { forwardedPorts: [] } };
699
+ }
700
+ if (params.action === "forward") {
701
+ const state = await ensureConnected(ctx);
702
+ const values = params.forwards?.trim().split(/\s+/).filter(Boolean) ?? configuredForwards(state.command);
703
+ if (!values.length) throw new Error(`No port mappings configured in ${REMOTE_CONFIG_FILE}`);
704
+ const specs = values.map(parseForwardSpec);
705
+ for (const spec of specs) await startForward(spec);
706
+ routeRemoteTools = false;
707
+ if (currentCtx) status(currentCtx);
708
+ const ports = specs.map((spec) => spec.localPort);
709
+ return { content: [{ type: "text", text: `Forwarded local ports: ${ports.join(", ")}; tools remain local.` }], details: { toolRouting: "local", forwardedPorts: ports } };
710
+ }
711
+ if (params.action === "chdir") {
712
+ await ensureConnected(ctx);
713
+ if (!params.cwd) throw new Error("cwd is required for chdir");
714
+ const resolved = await changeRemoteCwd(params.cwd, ctx);
715
+ return { content: [{ type: "text", text: `Remote working directory: ${resolved}` }], details: { connected: true, cwd: resolved } };
716
+ }
717
+ if (params.action === "exec") {
718
+ const state = await ensureConnected(ctx);
719
+ if (!params.remoteCommand) throw new Error("remoteCommand is required for exec");
720
+ const cdTarget = params.cwd === undefined ? standaloneCdTarget(params.remoteCommand) : undefined;
721
+ if (cdTarget !== undefined) {
722
+ const resolved = await changeRemoteCwd(cdTarget, ctx);
723
+ return { content: [{ type: "text", text: resolved }], details: { connected: true, cwd: resolved } };
724
+ }
725
+ const output = await withReconnect((client) => execRemote(client, `cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`));
726
+ const formatted = formatRemoteOutput(output.toString());
727
+ return { content: [{ type: "text", text: formatted.text }], details: { connected: true, cwd: state.cwd, fullOutputPath: formatted.fullOutputPath } };
728
+ }
729
+ const command = params.command || lastCommand || activeSshCommand();
730
+ if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
731
+ const state = await connectInteractive(command, ctx, params.cwd ?? configuredCwd(command));
732
+ if (!state) throw new Error(lastConnectionError || "SSH remote connection was cancelled or failed");
733
+ return { content: [{ type: "text", text: `Connected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
734
+ },
735
+ });
736
+
737
+ pi.registerCommand("remote", {
738
+ description: "Connect over SSH and manage endpoints: /remote | config | config ssh COMMAND | use USER@HOST:PORT | config cwd PATH | forward [MAPPINGS] | unforward | exec COMMAND | cd PATH | status | reload | off | forget",
739
+ handler: async (args, ctx) => {
740
+ const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
741
+ const action = input.toLowerCase();
742
+ if (action === "config") {
743
+ const config = loadRemoteConfig();
744
+ const rows = Object.entries(config.endpoints ?? {}).map(([key, endpoint]) => {
745
+ const active = key === config.activeEndpoint ? "*" : " ";
746
+ return `${active} ${key}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
747
+ });
748
+ ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\n${rows.join("\n") || "No saved endpoints"}`, "info");
749
+ return;
750
+ }
751
+ if (/^config\s+ssh\s+/i.test(input)) {
752
+ const command = input.replace(/^config\s+ssh\s+/i, "").trim();
753
+ try { parseSshCommand(command); }
754
+ catch (error) { ctx.ui.notify((error as Error).message, "error"); return; }
755
+ saveEndpointConfig(command, {}, true);
756
+ lastCommand = command;
757
+ ctx.ui.notify(`SSH endpoint saved and selected: ${parseSshCommand(command).label}`, "info");
758
+ return;
759
+ }
760
+ if (/^(?:config\s+)?use\s+/i.test(input)) {
761
+ const requested = input.replace(/^(?:config\s+)?use\s+/i, "").trim();
762
+ const config = loadRemoteConfig();
763
+ const keys = Object.keys(config.endpoints ?? {});
764
+ const matches = keys.filter((key) => key === requested || key.startsWith(requested));
765
+ if (matches.length !== 1) {
766
+ ctx.ui.notify(matches.length ? `Endpoint name is ambiguous: ${matches.join(", ")}` : `Endpoint not found: ${requested}`, "error");
767
+ return;
768
+ }
769
+ const key = matches[0]!;
770
+ const command = config.endpoints?.[key]?.sshCommand;
771
+ if (!command) { ctx.ui.notify(`Endpoint has no SSH command: ${key}`, "error"); return; }
772
+ if (remote && cacheId(remote) !== key) {
773
+ const previous = remote;
774
+ remote = null;
775
+ routeRemoteTools = false;
776
+ credentialCache.resume = undefined;
777
+ previous.client.end();
778
+ await stopForwards();
779
+ status(ctx);
780
+ }
781
+ saveRemoteConfig({ ...config, activeEndpoint: key });
782
+ lastCommand = command;
783
+ ctx.ui.notify(`Selected SSH remote endpoint: ${key}; use /remote to connect`, "info");
784
+ return;
785
+ }
786
+ if (/^config\s+cwd\s+/i.test(input)) {
787
+ const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
788
+ const command = lastCommand || activeSshCommand();
789
+ if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
790
+ saveEndpointConfig(command, { remoteCwd });
791
+ ctx.ui.notify(`Default SSH remote directory updated (${parseSshCommand(command).label}): ${remoteCwd}`, "info");
792
+ return;
793
+ }
794
+ if (/^config\s+forward(?:\s+|$)/i.test(input)) {
795
+ const forwards = input.replace(/^config\s+forward\s*/i, "").trim().split(/\s+/).filter(Boolean);
796
+ try { forwards.forEach(parseForwardSpec); }
797
+ catch (error) { ctx.ui.notify((error as Error).message, "error"); return; }
798
+ const command = lastCommand || activeSshCommand();
799
+ if (!command) { ctx.ui.notify("Configure an SSH endpoint first", "error"); return; }
800
+ saveEndpointConfig(command, { forwards });
801
+ ctx.ui.notify(`SSH remote port-forward configuration updated (${parseSshCommand(command).label}): ${forwards.join(", ") || "none"}`, "info");
802
+ return;
803
+ }
804
+ if (/^forward(?:\s+|$)/i.test(input)) {
805
+ try {
806
+ const state = await ensureConnected(ctx);
807
+ const supplied = input.replace(/^forward\s*/i, "").trim();
808
+ const values = supplied ? supplied.split(/\s+/) : configuredForwards(state.command);
809
+ if (!values.length) throw new Error("No port forwards configured; use /remote config forward LOCAL_PORT:REMOTE_HOST:REMOTE_PORT");
810
+ const specs = values.map(parseForwardSpec);
811
+ for (const spec of specs) await startForward(spec);
812
+ routeRemoteTools = false;
813
+ status(ctx);
814
+ ctx.ui.notify(`SSH remote port forwarding started; tools remain local in ${localCwd}: ${specs.map((spec) => `127.0.0.1:${spec.localPort}`).join(", ")}`, "info");
815
+ } catch (error) { ctx.ui.notify(`SSH remote port forwarding failed: ${(error as Error).message}`, "error"); }
816
+ return;
817
+ }
818
+ if (action === "unforward") {
819
+ await stopForwards();
820
+ ctx.ui.notify("Closed all extension-managed SSH remote port forwards", "info");
821
+ return;
822
+ }
823
+ if (/^exec\s+/i.test(input)) {
824
+ try {
825
+ const state = await ensureConnected(ctx);
826
+ const remoteCommand = input.replace(/^exec\s+/i, "");
827
+ const output = (await withReconnect((client) => execRemote(client, `cd -- ${quote(state.cwd)} && ${remoteCommand}`))).toString().trim();
828
+ ctx.ui.notify(output.slice(0, 4000) || "SSH remote command completed", "info");
829
+ } catch (error) { ctx.ui.notify(`SSH remote command failed: ${(error as Error).message}`, "error"); }
830
+ return;
831
+ }
832
+ if (["off", "disconnect", "exit"].includes(action)) { disconnect(ctx); return; }
833
+ if (action === "forget") { disconnect(ctx, true); return; }
834
+ if (action === "status") {
835
+ ctx.ui.notify(remote ? `${remote.label}:${remote.cwd}` : "SSH remote is disconnected", "info");
836
+ return;
837
+ }
838
+ if (["reload", "reconnect"].includes(action)) {
839
+ try { await reconnectRemote(); }
840
+ catch (error) { ctx.ui.notify(`SSH remote reconnection failed: ${(error as Error).message}`, "error"); }
841
+ return;
842
+ }
843
+ if (/^cd(?:\s+|$)/i.test(input)) {
844
+ if (!remote) { ctx.ui.notify("Connect to SSH remote first", "error"); return; }
845
+ const requested = input.replace(/^cd\s*/i, "").trim() || FALLBACK_REMOTE_CWD;
846
+ try {
847
+ const resolved = await changeRemoteCwd(requested, ctx);
848
+ ctx.ui.notify(`SSH remote path: ${resolved}`, "info");
849
+ } catch (error) { ctx.ui.notify(`Failed to change SSH remote path: ${(error as Error).message}`, "error"); }
850
+ return;
851
+ }
852
+ const command = input || await ctx.ui.input("SSH command:", lastCommand);
853
+ if (!command) return;
854
+ await connectInteractive(command, ctx);
855
+ },
856
+ });
857
+
858
+ pi.on("session_start", async (event, ctx) => {
859
+ currentCtx = ctx;
860
+ status(ctx);
861
+ if (event.reason === "reload" && credentialCache.resume) {
862
+ try { await reconnectRemote(); }
863
+ catch (error) { ctx.ui.notify(`SSH remote automatic login after reload failed: ${(error as Error).message}`, "error"); }
864
+ }
865
+ });
866
+ pi.on("session_shutdown", (event) => {
867
+ const previous = remote;
868
+ remote = null;
869
+ routeRemoteTools = false;
870
+ void stopForwards();
871
+ previous?.client.end();
872
+ if (event.reason !== "reload") credentialCache.resume = undefined;
873
+ });
874
+ pi.on("user_bash", async (event, ctx) => {
875
+ if (!remote || !routeRemoteTools) return undefined;
876
+ const cdTarget = standaloneCdTarget(event.command);
877
+ if (cdTarget === undefined) return { operations: remoteBashOps() };
878
+ try {
879
+ const resolved = await changeRemoteCwd(cdTarget, ctx);
880
+ return { result: { output: resolved, exitCode: 0, cancelled: false, truncated: false } };
881
+ } catch (error) {
882
+ return { result: { output: (error as Error).message, exitCode: 1, cancelled: false, truncated: false } };
883
+ }
884
+ });
885
+ pi.on("before_agent_start", (event) => remote && routeRemoteTools ? {
886
+ systemPrompt: event.systemPrompt.replace(
887
+ `Current working directory: ${localCwd}`,
888
+ `Current working directory: ${remote.cwd} (via SSH ${remote.label}). 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.`,
889
+ ),
890
+ } : undefined);
891
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "pi-ssh-remote",
3
+ "version": "0.1.0",
4
+ "description": "Persistent remote SSH workspaces for Pi.",
5
+ "type": "module",
6
+ "author": "Yutong Bian",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "ssh",
12
+ "remote-development"
13
+ ],
14
+ "files": [
15
+ "index.ts",
16
+ "README.md",
17
+ "README.zh-CN.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/petrichor20211/pi-ssh-remote.git"
26
+ },
27
+ "homepage": "https://github.com/petrichor20211/pi-ssh-remote#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/petrichor20211/pi-ssh-remote/issues"
30
+ },
31
+ "dependencies": {
32
+ "ssh2": "^1.17.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*",
38
+ "typebox": "*"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./index.ts"
43
+ ]
44
+ }
45
+ }