pi-ssh-remote 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -23,6 +23,15 @@ pi install npm:pi-ssh-remote
23
23
  /remote off # return to local tools
24
24
  ```
25
25
 
26
+ Remote command previews follow Pi's local Bash behavior and show the last 5 visual lines by default. Configure the default or override one `/remote exec` invocation:
27
+
28
+ ```text
29
+ /remote config display-lines 10
30
+ /remote exec --lines 20 COMMAND
31
+ ```
32
+
33
+ The `ssh_remote_control` tool's `exec` action also accepts `displayLines`. Preview settings affect only the collapsed UI; model output keeps Pi's 2000-line/50KB safety limits, with oversized output saved to a temporary file.
34
+
26
35
  Run `/remote config` to list endpoints and `/remote` to see all available subcommands.
27
36
 
28
37
  ## Authentication and security
package/README.zh-CN.md CHANGED
@@ -23,6 +23,15 @@ pi install npm:pi-ssh-remote
23
23
  /remote off # 返回本地工具
24
24
  ```
25
25
 
26
+ 远程命令预览与 Pi 本地 Bash 一致,默认展示最后 5 个视觉行。可以修改默认值,或只覆盖某一次 `/remote exec`:
27
+
28
+ ```text
29
+ /remote config display-lines 10
30
+ /remote exec --lines 20 COMMAND
31
+ ```
32
+
33
+ `ssh_remote_control` 工具的 `exec` 操作也支持 `displayLines`。预览设置只影响折叠界面;提供给模型的输出仍采用 Pi 的 2000 行/50KB 安全限制,超限完整输出会保存到临时文件。
34
+
26
35
  使用 `/remote config` 查看服务器,使用 `/remote` 查看全部子命令。
27
36
 
28
37
  ## 认证与安全
package/index.ts CHANGED
@@ -23,13 +23,14 @@ import {
23
23
  createReadTool,
24
24
  createWriteTool,
25
25
  formatSize,
26
+ keyHint,
26
27
  truncateTail,
27
28
  type BashOperations,
28
29
  type EditOperations,
29
30
  type ReadOperations,
30
31
  type WriteOperations,
31
32
  } from "@earendil-works/pi-coding-agent";
32
- import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
33
+ import { CURSOR_MARKER, Key, Text, matchesKey, truncateToWidth, type Component, type Focusable } from "@earendil-works/pi-tui";
33
34
 
34
35
  interface ParsedSsh {
35
36
  host: string;
@@ -58,6 +59,7 @@ interface RemoteEndpointConfig {
58
59
  interface RemoteConfig {
59
60
  activeEndpoint?: string;
60
61
  endpoints?: Record<string, RemoteEndpointConfig>;
62
+ displayLines?: number;
61
63
  /** Legacy fields migrated into endpoints on the next config write. */
62
64
  sshCommand?: string;
63
65
  remoteCwd?: string;
@@ -74,6 +76,7 @@ const AGENT_DIR = join(process.env.HOME || ".", CONFIG_DIR_NAME, "agent");
74
76
  const KNOWN_HOSTS_FILE = join(AGENT_DIR, "ssh-remote-known-hosts.json");
75
77
  const REMOTE_CONFIG_FILE = join(AGENT_DIR, "ssh-remote-config.json");
76
78
  const FALLBACK_REMOTE_CWD = "~";
79
+ const DEFAULT_DISPLAY_LINES = 5;
77
80
  const CACHE_KEY = "__piHpcCredentialCacheV1";
78
81
  const cacheHost = globalThis as typeof globalThis & { [CACHE_KEY]?: CredentialCache };
79
82
  const credentialCache = cacheHost[CACHE_KEY] ??= { passwords: new Map<string, string>() };
@@ -151,6 +154,19 @@ function quote(value: string): string {
151
154
  return `'${value.replace(/'/g, `'"'"'`)}'`;
152
155
  }
153
156
 
157
+ function parseDisplayLines(value: unknown): number {
158
+ const lines = typeof value === "number" ? value : Number(value);
159
+ if (!Number.isInteger(lines) || lines < 1 || lines > DEFAULT_MAX_LINES) {
160
+ throw new Error(`Display lines must be an integer from 1 to ${DEFAULT_MAX_LINES}`);
161
+ }
162
+ return lines;
163
+ }
164
+
165
+ function configuredDisplayLines(config = loadRemoteConfig()): number {
166
+ try { return parseDisplayLines(config.displayLines ?? DEFAULT_DISPLAY_LINES); }
167
+ catch { return DEFAULT_DISPLAY_LINES; }
168
+ }
169
+
154
170
  function commandFromEndpointKey(key: string): string | undefined {
155
171
  const match = key.match(/^([^@]+)@(.+):(\d+)$/);
156
172
  if (!match) return undefined;
@@ -182,10 +198,14 @@ function normalizeRemoteConfig(config: RemoteConfig): RemoteConfig {
182
198
  }
183
199
  if (activeEndpoint && !endpoints[activeEndpoint]) activeEndpoint = undefined;
184
200
  activeEndpoint ??= Object.keys(endpoints)[0];
201
+ let displayLines: number | undefined;
202
+ try { displayLines = config.displayLines === undefined ? undefined : parseDisplayLines(config.displayLines); }
203
+ catch { displayLines = undefined; }
185
204
 
186
205
  return {
187
206
  ...(activeEndpoint ? { activeEndpoint } : {}),
188
207
  ...(Object.keys(endpoints).length ? { endpoints } : {}),
208
+ ...(displayLines !== undefined ? { displayLines } : {}),
189
209
  };
190
210
  }
191
211
 
@@ -241,15 +261,54 @@ function displayFingerprint(hex: string): string {
241
261
  return `SHA256:${Buffer.from(hex, "hex").toString("base64").replace(/=+$/, "")}`;
242
262
  }
243
263
 
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." };
264
+ function formatRemoteOutput(output: string) {
265
+ const truncation = truncateTail(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
266
+ const content = truncation.content || "Remote command completed.";
267
+ if (!truncation.truncated) return { text: content, content, truncation };
247
268
 
248
269
  const outputDir = mkdtempSync(join(tmpdir(), "pi-ssh-remote-output-"));
249
270
  const fullOutputPath = join(outputDir, "output.log");
250
271
  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 };
272
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
273
+ const limit = truncation.truncatedBy === "bytes" ? ` (${formatSize(DEFAULT_MAX_BYTES)} limit)` : "";
274
+ const text = `${content}\n\n[Showing lines ${startLine}-${truncation.totalLines} of ${truncation.totalLines}${limit}. Full output: ${fullOutputPath}]`;
275
+ return { text, content, truncation, fullOutputPath };
276
+ }
277
+
278
+ function previewRemoteOutput(output: string, displayLines: number): string {
279
+ return truncateTail(output, { maxLines: displayLines, maxBytes: DEFAULT_MAX_BYTES }).content || "Remote command completed.";
280
+ }
281
+
282
+ function renderRemoteControlResult(result: any, expanded: boolean, theme: any): Component {
283
+ const fallback = result.content?.find((item: any) => item.type === "text")?.text ?? "";
284
+ const details = result.details;
285
+ if (details?.action !== "exec") return new Text(fallback, 0, 0);
286
+
287
+ const output = details.output || fallback;
288
+ const displayLines = details.displayLines || DEFAULT_DISPLAY_LINES;
289
+ const warnings = [
290
+ ...(details.fullOutputPath ? [`Full output: ${details.fullOutputPath}`] : []),
291
+ ...(details.truncation?.truncated ? [`Truncated: showing ${details.truncation.outputLines} of ${details.truncation.totalLines} lines`] : []),
292
+ ];
293
+ const warning = warnings.length ? warnings.join(". ") : undefined;
294
+
295
+ if (expanded) {
296
+ return new Text(`${output}${warning ? `\n${theme.fg("warning", `[${warning}]`)}` : ""}`, 0, 0);
297
+ }
298
+
299
+ return {
300
+ render(width: number) {
301
+ const styled = output.split("\n").map((line: string) => theme.fg("toolOutput", line)).join("\n");
302
+ const visualLines = new Text(styled, 0, 0).render(width);
303
+ const shown = visualLines.slice(-displayLines);
304
+ const skipped = visualLines.length - shown.length;
305
+ const hint = skipped > 0
306
+ ? [theme.fg("muted", `... (${skipped} earlier lines, ${keyHint("app.tools.expand", "to expand")})`)]
307
+ : [];
308
+ return [...hint, ...shown, ...(warning ? [theme.fg("warning", `[${warning}]`)] : [])];
309
+ },
310
+ invalidate() {},
311
+ };
253
312
  }
254
313
 
255
314
  function probeFingerprint(config: ParsedSsh): Promise<string> {
@@ -664,7 +723,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
664
723
  pi.registerTool({
665
724
  name: "ssh_remote_control",
666
725
  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.",
726
+ description: "Connect, reconnect, change the persistent remote working directory, inspect, forward ports, run remote SSH commands, or disconnect the configured SSH environment. Exec output uses a configurable collapsed preview (5 visual lines by default), while model output is limited to 50KB or 2000 lines and saved to a local temporary file when truncated. Passwords are never accepted as arguments and are cached only in process memory.",
668
727
  promptSnippet: "Control the configured remote SSH connection, working directory, and local port forwarding",
669
728
  promptGuidelines: [
670
729
  "Use ssh_remote_control when the user asks the agent to enter, reconnect, inspect, or leave a remote SSH environment.",
@@ -677,6 +736,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
677
736
  cwd: Type.Optional(Type.String({ description: "Remote working directory; required for chdir, and a one-command override for exec" })),
678
737
  forwards: Type.Optional(Type.String({ description: "Space-separated LOCAL_PORT:REMOTE_HOST:REMOTE_PORT mappings; defaults to ssh-remote-config.json" })),
679
738
  remoteCommand: Type.Optional(Type.String({ description: "Remote shell command for the exec action" })),
739
+ displayLines: Type.Optional(Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES, description: "Collapsed visual lines for exec output; defaults to the /remote config display-lines setting (5 initially)" })),
680
740
  }),
681
741
  async execute(_id, params, _signal, _update, ctx) {
682
742
  if (params.action === "status") {
@@ -722,9 +782,21 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
722
782
  const resolved = await changeRemoteCwd(cdTarget, ctx);
723
783
  return { content: [{ type: "text", text: resolved }], details: { connected: true, cwd: resolved } };
724
784
  }
785
+ const displayLines = parseDisplayLines(params.displayLines ?? configuredDisplayLines());
725
786
  const output = await withReconnect((client) => execRemote(client, `cd -- ${quote(params.cwd ?? state.cwd)} && ${params.remoteCommand}`));
726
787
  const formatted = formatRemoteOutput(output.toString());
727
- return { content: [{ type: "text", text: formatted.text }], details: { connected: true, cwd: state.cwd, fullOutputPath: formatted.fullOutputPath } };
788
+ return {
789
+ content: [{ type: "text", text: formatted.text }],
790
+ details: {
791
+ action: "exec",
792
+ connected: true,
793
+ cwd: state.cwd,
794
+ displayLines,
795
+ output: formatted.content,
796
+ truncation: formatted.truncation.truncated ? formatted.truncation : undefined,
797
+ fullOutputPath: formatted.fullOutputPath,
798
+ },
799
+ };
728
800
  }
729
801
  const command = params.command || lastCommand || activeSshCommand();
730
802
  if (!command) throw new Error(`No SSH endpoint configured. Set ${REMOTE_CONFIG_FILE} or pass command.`);
@@ -732,10 +804,13 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
732
804
  if (!state) throw new Error(lastConnectionError || "SSH remote connection was cancelled or failed");
733
805
  return { content: [{ type: "text", text: `Connected: ${state.label}:${state.cwd}` }], details: { connected: true, cwd: state.cwd } };
734
806
  },
807
+ renderResult(result, { expanded }, theme) {
808
+ return renderRemoteControlResult(result, expanded, theme);
809
+ },
735
810
  });
736
811
 
737
812
  pi.registerCommand("remote", {
738
- description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | forward [MAPPINGS] | unforward | exec COMMAND | cd PATH | status | reload | off | forget",
813
+ description: "Connect over SSH and manage endpoints: /remote | ssh USER@HOST [-p PORT] | config | use USER@HOST:PORT | config cwd PATH | config display-lines N | forward [MAPPINGS] | unforward | exec [--lines N] COMMAND | cd PATH | status | reload | off | forget",
739
814
  handler: async (args, ctx) => {
740
815
  const input = args.trim().replace(/^\/?remote(?:\s+|$)/i, "").trim();
741
816
  const action = input.toLowerCase();
@@ -745,7 +820,7 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
745
820
  const active = key === config.activeEndpoint ? "*" : " ";
746
821
  return `${active} ${key}\n SSH: ${endpoint.sshCommand}\n cwd: ${endpoint.remoteCwd || FALLBACK_REMOTE_CWD}\n forward: ${endpoint.forwards?.join(", ") || "none"}`;
747
822
  });
748
- ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\n${rows.join("\n") || "No saved endpoints"}`, "info");
823
+ ctx.ui.notify(`SSH remote configuration: ${REMOTE_CONFIG_FILE}\nDisplay lines: ${configuredDisplayLines(config)}\n${rows.join("\n") || "No saved endpoints"}`, "info");
749
824
  return;
750
825
  }
751
826
  if (/^ssh\s+/i.test(input)) {
@@ -784,6 +859,14 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
784
859
  ctx.ui.notify(`Selected SSH remote endpoint: ${key}; use /remote to connect`, "info");
785
860
  return;
786
861
  }
862
+ if (/^config\s+display-lines\s+/i.test(input)) {
863
+ try {
864
+ const displayLines = parseDisplayLines(input.replace(/^config\s+display-lines\s+/i, "").trim());
865
+ saveRemoteConfig({ ...loadRemoteConfig(), displayLines });
866
+ ctx.ui.notify(`SSH remote command preview updated: ${displayLines} lines`, "info");
867
+ } catch (error) { ctx.ui.notify((error as Error).message, "error"); }
868
+ return;
869
+ }
787
870
  if (/^config\s+cwd\s+/i.test(input)) {
788
871
  const remoteCwd = input.replace(/^config\s+cwd\s+/i, "").trim();
789
872
  const command = lastCommand || activeSshCommand();
@@ -824,9 +907,18 @@ export default function sshRemoteExtension(pi: ExtensionAPI) {
824
907
  if (/^exec\s+/i.test(input)) {
825
908
  try {
826
909
  const state = await ensureConnected(ctx);
827
- const remoteCommand = input.replace(/^exec\s+/i, "");
910
+ const execInput = input.replace(/^exec\s+/i, "");
911
+ const linesMatch = execInput.match(/^--lines\s+(\S+)\s+([\s\S]+)$/i);
912
+ const displayLines = linesMatch ? parseDisplayLines(linesMatch[1]) : configuredDisplayLines();
913
+ const remoteCommand = linesMatch ? linesMatch[2]! : execInput;
828
914
  const output = (await withReconnect((client) => execRemote(client, `cd -- ${quote(state.cwd)} && ${remoteCommand}`))).toString().trim();
829
- ctx.ui.notify(output.slice(0, 4000) || "SSH remote command completed", "info");
915
+ const formatted = formatRemoteOutput(output);
916
+ const preview = previewRemoteOutput(formatted.content, displayLines);
917
+ const omitted = formatted.truncation.totalLines > displayLines
918
+ ? `\n\n[Showing last ${Math.min(displayLines, formatted.truncation.totalLines)} of ${formatted.truncation.totalLines} lines]`
919
+ : "";
920
+ const fullOutput = formatted.fullOutputPath ? `\n[Full output: ${formatted.fullOutputPath}]` : "";
921
+ ctx.ui.notify(`${preview}${omitted}${fullOutput}`, "info");
830
922
  } catch (error) { ctx.ui.notify(`SSH remote command failed: ${(error as Error).message}`, "error"); }
831
923
  return;
832
924
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ssh-remote",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Persistent remote SSH workspaces for Pi.",
5
5
  "type": "module",
6
6
  "author": "Yutong Bian",