@oh-my-pi/pi-coding-agent 3.33.0 → 3.34.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +34 -9
  2. package/docs/custom-tools.md +1 -1
  3. package/docs/extensions.md +4 -4
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +4 -8
  6. package/examples/custom-tools/README.md +2 -2
  7. package/examples/extensions/README.md +1 -1
  8. package/examples/extensions/todo.ts +1 -1
  9. package/examples/hooks/custom-compaction.ts +4 -2
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +1 -1
  14. package/package.json +5 -5
  15. package/src/capability/ssh.ts +42 -0
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +19 -5
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/extensions/runner.ts +1 -1
  25. package/src/core/extensions/types.ts +1 -1
  26. package/src/core/extensions/wrapper.ts +1 -1
  27. package/src/core/hooks/runner.ts +2 -2
  28. package/src/core/hooks/types.ts +1 -1
  29. package/src/core/index.ts +11 -0
  30. package/src/core/messages.ts +1 -1
  31. package/src/core/model-registry.ts +1 -1
  32. package/src/core/model-resolver.ts +7 -6
  33. package/src/core/sdk.ts +26 -2
  34. package/src/core/session-manager.ts +1 -1
  35. package/src/core/ssh/connection-manager.ts +466 -0
  36. package/src/core/ssh/ssh-executor.ts +190 -0
  37. package/src/core/ssh/sshfs-mount.ts +162 -0
  38. package/src/core/ssh-executor.ts +5 -0
  39. package/src/core/system-prompt.ts +424 -1
  40. package/src/core/title-generator.ts +2 -2
  41. package/src/core/tools/index.test.ts +1 -0
  42. package/src/core/tools/index.ts +3 -0
  43. package/src/core/tools/output.ts +1 -1
  44. package/src/core/tools/read.ts +24 -11
  45. package/src/core/tools/renderers.ts +2 -0
  46. package/src/core/tools/ssh.ts +302 -0
  47. package/src/core/tools/task/index.ts +1 -1
  48. package/src/core/tools/task/types.ts +1 -1
  49. package/src/core/tools/task/worker.ts +1 -1
  50. package/src/core/voice.ts +1 -1
  51. package/src/discovery/index.ts +3 -0
  52. package/src/discovery/ssh.ts +162 -0
  53. package/src/main.ts +1 -1
  54. package/src/modes/interactive/components/assistant-message.ts +1 -1
  55. package/src/modes/interactive/components/custom-message.ts +1 -1
  56. package/src/modes/interactive/components/footer.ts +1 -1
  57. package/src/modes/interactive/components/hook-message.ts +1 -1
  58. package/src/modes/interactive/components/model-selector.ts +1 -1
  59. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  60. package/src/modes/interactive/components/status-line.ts +1 -1
  61. package/src/modes/interactive/interactive-mode.ts +1 -1
  62. package/src/modes/print-mode.ts +1 -1
  63. package/src/modes/rpc/rpc-client.ts +1 -1
  64. package/src/modes/rpc/rpc-types.ts +1 -1
  65. package/src/prompts/system-prompt.md +4 -0
  66. package/src/prompts/tools/ssh.md +74 -0
  67. package/src/utils/image-resize.ts +1 -1
@@ -2,7 +2,9 @@
2
2
  * System prompt construction and project context loading
3
3
  */
4
4
 
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
6
8
  import chalk from "chalk";
7
9
  import { contextFileCapability } from "../capability/context-file";
8
10
  import type { Rule } from "../capability/rule";
@@ -70,6 +72,7 @@ const toolDescriptions: Record<ToolName, string> = {
70
72
  ask: "Ask user for input or clarification",
71
73
  read: "Read file contents",
72
74
  bash: "Execute bash commands (npm, docker, etc.)",
75
+ ssh: "Execute commands on remote hosts via SSH",
73
76
  edit: "Make surgical edits to files (find exact text and replace)",
74
77
  write: "Create or overwrite files",
75
78
  grep: "Search file contents for patterns (respects .gitignore)",
@@ -126,6 +129,406 @@ function buildPromptFooter(dateTime: string, cwd: string): string {
126
129
  return `Current date and time: ${dateTime}\nCurrent working directory: ${cwd}`;
127
130
  }
128
131
 
132
+ function execCommand(args: string[]): string | null {
133
+ const result = Bun.spawnSync(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
134
+ if (result.exitCode !== 0) return null;
135
+ const output = result.stdout.toString().trim();
136
+ return output.length > 0 ? output : null;
137
+ }
138
+
139
+ function execIfExists(command: string, args: string[]): string | null {
140
+ if (!Bun.which(command)) return null;
141
+ return execCommand([command, ...args]);
142
+ }
143
+
144
+ function firstNonEmpty(values: Array<string | undefined | null>): string | null {
145
+ for (const value of values) {
146
+ const trimmed = value?.trim();
147
+ if (trimmed) return trimmed;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function firstNonEmptyLine(value: string | null): string | null {
153
+ if (!value) return null;
154
+ const line = value
155
+ .split("\n")
156
+ .map((entry) => entry.trim())
157
+ .filter(Boolean)[0];
158
+ return line ?? null;
159
+ }
160
+
161
+ function parseWmicTable(output: string, header: string): string | null {
162
+ const lines = output
163
+ .split("\n")
164
+ .map((line) => line.trim())
165
+ .filter(Boolean);
166
+ const filtered = lines.filter((line) => line.toLowerCase() !== header.toLowerCase());
167
+ return filtered[0] ?? null;
168
+ }
169
+
170
+ function parseKeyValueOutput(output: string): Record<string, string> {
171
+ const result: Record<string, string> = {};
172
+ for (const line of output.split("\n")) {
173
+ const trimmed = line.trim();
174
+ if (!trimmed) continue;
175
+ const [key, ...rest] = trimmed.split("=");
176
+ if (!key || rest.length === 0) continue;
177
+ const value = rest.join("=").trim();
178
+ if (value) result[key.trim()] = value;
179
+ }
180
+ return result;
181
+ }
182
+
183
+ function stripQuotes(value: string): string {
184
+ return value.replace(/^"|"$/g, "");
185
+ }
186
+
187
+ function getOsName(): string {
188
+ switch (process.platform) {
189
+ case "win32":
190
+ return "Windows";
191
+ case "darwin":
192
+ return "macOS";
193
+ case "linux":
194
+ return "Linux";
195
+ case "freebsd":
196
+ return "FreeBSD";
197
+ case "openbsd":
198
+ return "OpenBSD";
199
+ case "netbsd":
200
+ return "NetBSD";
201
+ case "aix":
202
+ return "AIX";
203
+ default:
204
+ return process.platform || "unknown";
205
+ }
206
+ }
207
+
208
+ function getKernelVersion(): string {
209
+ if (process.platform === "win32") {
210
+ return execCommand(["cmd", "/c", "ver"]) ?? "unknown";
211
+ }
212
+
213
+ return execCommand(["uname", "-sr"]) ?? "unknown";
214
+ }
215
+
216
+ function getOsDistro(): string | null {
217
+ switch (process.platform) {
218
+ case "win32": {
219
+ const output = execIfExists("wmic", ["os", "get", "Caption,Version", "/value"]);
220
+ if (!output) return null;
221
+ const parsed = parseKeyValueOutput(output);
222
+ const caption = parsed.Caption;
223
+ const version = parsed.Version;
224
+ if (caption && version) return `${caption} ${version}`.trim();
225
+ return caption ?? version ?? null;
226
+ }
227
+ case "darwin": {
228
+ const name = firstNonEmptyLine(execIfExists("sw_vers", ["-productName"]));
229
+ const version = firstNonEmptyLine(execIfExists("sw_vers", ["-productVersion"]));
230
+ if (name && version) return `${name} ${version}`.trim();
231
+ return name ?? version ?? null;
232
+ }
233
+ case "linux": {
234
+ const lsb = firstNonEmptyLine(execIfExists("lsb_release", ["-ds"]));
235
+ if (lsb) return stripQuotes(lsb);
236
+ const osRelease = execIfExists("cat", ["/etc/os-release"]);
237
+ if (!osRelease) return null;
238
+ const parsed = parseKeyValueOutput(osRelease);
239
+ const pretty = parsed.PRETTY_NAME ?? parsed.NAME;
240
+ const version = parsed.VERSION ?? parsed.VERSION_ID;
241
+ if (pretty) return stripQuotes(pretty);
242
+ if (parsed.NAME && version) return `${stripQuotes(parsed.NAME)} ${stripQuotes(version)}`.trim();
243
+ return parsed.NAME ? stripQuotes(parsed.NAME) : null;
244
+ }
245
+ default:
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function getCpuArch(): string {
251
+ return process.arch || "unknown";
252
+ }
253
+
254
+ function getCpuModel(): string | null {
255
+ switch (process.platform) {
256
+ case "win32": {
257
+ const output = execIfExists("wmic", ["cpu", "get", "Name"]);
258
+ return output ? parseWmicTable(output, "Name") : null;
259
+ }
260
+ case "darwin": {
261
+ return firstNonEmptyLine(execIfExists("sysctl", ["-n", "machdep.cpu.brand_string"]));
262
+ }
263
+ case "linux": {
264
+ const lscpu = execIfExists("lscpu", []);
265
+ if (lscpu) {
266
+ const match = lscpu
267
+ .split("\n")
268
+ .map((line) => line.trim())
269
+ .find((line) => line.toLowerCase().startsWith("model name:"));
270
+ if (match) return match.split(":").slice(1).join(":").trim();
271
+ }
272
+ const cpuInfo = execIfExists("cat", ["/proc/cpuinfo"]);
273
+ if (!cpuInfo) return null;
274
+ for (const line of cpuInfo.split("\n")) {
275
+ const [key, ...rest] = line.split(":");
276
+ if (!key || rest.length === 0) continue;
277
+ const normalized = key.trim().toLowerCase();
278
+ if (normalized === "model name" || normalized === "hardware" || normalized === "processor") {
279
+ return rest.join(":").trim();
280
+ }
281
+ }
282
+ return null;
283
+ }
284
+ default:
285
+ return null;
286
+ }
287
+ }
288
+
289
+ function getGpuModel(): string | null {
290
+ switch (process.platform) {
291
+ case "win32": {
292
+ const output = execIfExists("wmic", ["path", "win32_VideoController", "get", "name"]);
293
+ return output ? parseWmicTable(output, "Name") : null;
294
+ }
295
+ case "linux": {
296
+ const output = execIfExists("lspci", []);
297
+ if (!output) return null;
298
+ const gpus: Array<{ name: string; priority: number }> = [];
299
+ for (const line of output.split("\n")) {
300
+ if (!/(VGA|3D|Display)/i.test(line)) continue;
301
+ const parts = line.split(":");
302
+ const name = parts.length > 1 ? parts.slice(1).join(":").trim() : line.trim();
303
+ const nameLower = name.toLowerCase();
304
+ // Skip BMC/server management adapters
305
+ if (/aspeed|matrox g200|mgag200/i.test(name)) continue;
306
+ // Prioritize discrete GPUs
307
+ let priority = 0;
308
+ if (
309
+ nameLower.includes("nvidia") ||
310
+ nameLower.includes("geforce") ||
311
+ nameLower.includes("quadro") ||
312
+ nameLower.includes("rtx")
313
+ ) {
314
+ priority = 3;
315
+ } else if (nameLower.includes("amd") || nameLower.includes("radeon") || nameLower.includes("rx ")) {
316
+ priority = 3;
317
+ } else if (nameLower.includes("intel")) {
318
+ priority = 1;
319
+ } else {
320
+ priority = 2;
321
+ }
322
+ gpus.push({ name, priority });
323
+ }
324
+ if (gpus.length === 0) return null;
325
+ gpus.sort((a, b) => b.priority - a.priority);
326
+ return gpus[0].name;
327
+ }
328
+ default:
329
+ return null;
330
+ }
331
+ }
332
+
333
+ function getShellName(): string {
334
+ const shell = firstNonEmpty([process.env.SHELL, process.env.ComSpec]);
335
+ return shell ?? "unknown";
336
+ }
337
+
338
+ function getTerminalName(): string {
339
+ const termProgram = process.env.TERM_PROGRAM;
340
+ const termProgramVersion = process.env.TERM_PROGRAM_VERSION;
341
+ if (termProgram) {
342
+ return termProgramVersion ? `${termProgram} ${termProgramVersion}` : termProgram;
343
+ }
344
+
345
+ if (process.env.WT_SESSION) return "Windows Terminal";
346
+
347
+ const term = firstNonEmpty([process.env.TERM, process.env.COLORTERM, process.env.TERMINAL_EMULATOR]);
348
+ return term ?? "unknown";
349
+ }
350
+
351
+ function normalizeDesktopValue(value: string): string {
352
+ const trimmed = value.trim();
353
+ if (!trimmed) return "unknown";
354
+ const parts = trimmed
355
+ .split(":")
356
+ .map((part) => part.trim())
357
+ .filter(Boolean);
358
+ return parts[0] ?? trimmed;
359
+ }
360
+
361
+ function getDesktopEnvironment(): string {
362
+ if (process.env.KDE_FULL_SESSION === "true") return "KDE";
363
+ const raw = firstNonEmpty([
364
+ process.env.XDG_CURRENT_DESKTOP,
365
+ process.env.DESKTOP_SESSION,
366
+ process.env.XDG_SESSION_DESKTOP,
367
+ process.env.GDMSESSION,
368
+ ]);
369
+ return raw ? normalizeDesktopValue(raw) : "unknown";
370
+ }
371
+
372
+ function matchKnownWindowManager(value: string): string | null {
373
+ const normalized = value.toLowerCase();
374
+ const candidates = [
375
+ "sway",
376
+ "i3",
377
+ "i3wm",
378
+ "bspwm",
379
+ "openbox",
380
+ "awesome",
381
+ "herbstluftwm",
382
+ "fluxbox",
383
+ "icewm",
384
+ "dwm",
385
+ "hyprland",
386
+ "wayfire",
387
+ "river",
388
+ "labwc",
389
+ "qtile",
390
+ ];
391
+ for (const candidate of candidates) {
392
+ if (normalized.includes(candidate)) return candidate;
393
+ }
394
+ return null;
395
+ }
396
+
397
+ function getWindowManager(): string {
398
+ const explicit = firstNonEmpty([process.env.WINDOWMANAGER]);
399
+ if (explicit) return explicit;
400
+
401
+ const desktop = firstNonEmpty([process.env.XDG_CURRENT_DESKTOP, process.env.DESKTOP_SESSION]);
402
+ if (desktop) {
403
+ const matched = matchKnownWindowManager(desktop);
404
+ if (matched) return matched;
405
+ }
406
+
407
+ return "unknown";
408
+ }
409
+
410
+ /** Cached system info structure */
411
+ interface SystemInfoCache {
412
+ os: string;
413
+ distro: string;
414
+ kernel: string;
415
+ arch: string;
416
+ cpu: string;
417
+ gpu: string;
418
+ disk: string;
419
+ }
420
+
421
+ function getSystemInfoCachePath(): string {
422
+ return join(homedir(), ".omp", "system_info.json");
423
+ }
424
+
425
+ function loadSystemInfoCache(): SystemInfoCache | null {
426
+ try {
427
+ const cachePath = getSystemInfoCachePath();
428
+ if (!existsSync(cachePath)) return null;
429
+ const content = readFileSync(cachePath, "utf-8");
430
+ return JSON.parse(content) as SystemInfoCache;
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ function saveSystemInfoCache(info: SystemInfoCache): void {
437
+ try {
438
+ const cachePath = getSystemInfoCachePath();
439
+ const dir = join(homedir(), ".omp");
440
+ if (!existsSync(dir)) {
441
+ mkdirSync(dir, { recursive: true });
442
+ }
443
+ writeFileSync(cachePath, JSON.stringify(info, null, "\t"), "utf-8");
444
+ } catch {
445
+ // Silently ignore cache write failures
446
+ }
447
+ }
448
+
449
+ function collectSystemInfo(): SystemInfoCache {
450
+ return {
451
+ os: getOsName(),
452
+ distro: getOsDistro() ?? "unknown",
453
+ kernel: getKernelVersion(),
454
+ arch: getCpuArch(),
455
+ cpu: getCpuModel() ?? "unknown",
456
+ gpu: getGpuModel() ?? "unknown",
457
+ disk: getDiskInfo() ?? "unknown",
458
+ };
459
+ }
460
+
461
+ function formatBytes(bytes: number): string {
462
+ if (bytes < 1024) return `${bytes}B`;
463
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
464
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
465
+ if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
466
+ return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)}TB`;
467
+ }
468
+
469
+ function getDiskInfo(): string | null {
470
+ switch (process.platform) {
471
+ case "win32": {
472
+ const output = execIfExists("wmic", ["logicaldisk", "get", "Caption,Size,FreeSpace", "/format:csv"]);
473
+ if (!output) return null;
474
+ const lines = output.split("\n").filter((l) => l.trim() && !l.startsWith("Node"));
475
+ const disks: string[] = [];
476
+ for (const line of lines) {
477
+ const parts = line.split(",");
478
+ if (parts.length < 4) continue;
479
+ const caption = parts[1]?.trim();
480
+ const freeSpace = Number.parseInt(parts[2]?.trim() ?? "", 10);
481
+ const size = Number.parseInt(parts[3]?.trim() ?? "", 10);
482
+ if (!caption || Number.isNaN(size) || size === 0) continue;
483
+ const used = size - (Number.isNaN(freeSpace) ? 0 : freeSpace);
484
+ const pct = Math.round((used / size) * 100);
485
+ disks.push(`${caption} ${formatBytes(used)}/${formatBytes(size)} (${pct}%)`);
486
+ }
487
+ return disks.length > 0 ? disks.join(", ") : null;
488
+ }
489
+ case "linux":
490
+ case "darwin": {
491
+ const output = execIfExists("df", ["-h", "/"]);
492
+ if (!output) return null;
493
+ const lines = output.split("\n");
494
+ if (lines.length < 2) return null;
495
+ const parts = lines[1].split(/\s+/);
496
+ if (parts.length < 5) return null;
497
+ const size = parts[1];
498
+ const used = parts[2];
499
+ const pct = parts[4];
500
+ return `/ ${used}/${size} (${pct})`;
501
+ }
502
+ default:
503
+ return null;
504
+ }
505
+ }
506
+
507
+ function formatEnvironmentInfo(): string {
508
+ // Load cached system info or collect fresh
509
+ let sysInfo = loadSystemInfoCache();
510
+ if (!sysInfo) {
511
+ sysInfo = collectSystemInfo();
512
+ saveSystemInfoCache(sysInfo);
513
+ }
514
+
515
+ // Session-specific values (not cached)
516
+ const items: Array<[string, string]> = [
517
+ ["OS", sysInfo.os],
518
+ ["Distro", sysInfo.distro],
519
+ ["Kernel", sysInfo.kernel],
520
+ ["Arch", sysInfo.arch],
521
+ ["CPU", sysInfo.cpu],
522
+ ["GPU", sysInfo.gpu],
523
+ ["Disk", sysInfo.disk],
524
+ ["Shell", getShellName()],
525
+ ["Terminal", getTerminalName()],
526
+ ["DE", getDesktopEnvironment()],
527
+ ["WM", getWindowManager()],
528
+ ];
529
+ return items.map(([label, value]) => `- ${label}: ${value}`).join("\n");
530
+ }
531
+
129
532
  /**
130
533
  * Generate anti-bash rules section if the agent has both bash and specialized tools.
131
534
  * Only include rules for tools that are actually available.
@@ -201,6 +604,24 @@ function generateAntiBashRules(tools: ToolName[]): string | null {
201
604
  );
202
605
  }
203
606
 
607
+ // Add SSH remote filesystem guidance if available
608
+ const hasSSH = tools.includes("ssh");
609
+ if (hasSSH) {
610
+ lines.push("\n### SSH Command Execution");
611
+ lines.push(
612
+ "**Critical**: Each SSH host runs a specific shell. **You MUST match commands to the host's shell type**.",
613
+ );
614
+ lines.push("Check the host list in the ssh tool description. Shell types:");
615
+ lines.push("- linux/bash, linux/zsh, macos/bash, macos/zsh: ls, cat, grep, find, ps, df, uname");
616
+ lines.push("- windows/bash, windows/sh: ls, cat, grep, find (Windows with WSL/Cygwin — Unix commands)");
617
+ lines.push("- windows/cmd: dir, type, findstr, tasklist, systeminfo");
618
+ lines.push("- windows/powershell: Get-ChildItem, Get-Content, Select-String, Get-Process");
619
+ lines.push("");
620
+ lines.push("### SSH Filesystems");
621
+ lines.push("Mounted at `~/.omp/remote/<hostname>/` — use read/edit/write tools directly.");
622
+ lines.push("Windows paths need colon: `~/.omp/remote/host/C:/Users/...` not `C/Users/...`\n");
623
+ }
624
+
204
625
  // Add search-first protocol
205
626
  if (hasGrep || hasFind) {
206
627
  lines.push("\n### Search-First Protocol");
@@ -389,6 +810,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
389
810
 
390
811
  // Generate anti-bash rules (returns null if not applicable)
391
812
  const antiBashSection = generateAntiBashRules(Array.from(tools?.keys() ?? []));
813
+ const environmentInfo = formatEnvironmentInfo();
392
814
 
393
815
  // Build guidelines based on which tools are actually available
394
816
  const guidelinesList: string[] = [];
@@ -446,6 +868,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
446
868
  toolsList,
447
869
  antiBashSection: antiBashBlock,
448
870
  guidelines,
871
+ environmentInfo,
449
872
  readmePath,
450
873
  docsPath,
451
874
  examplesPath,
@@ -2,8 +2,8 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
 
5
- import type { Api, Model } from "@mariozechner/pi-ai";
6
- import { completeSimple } from "@mariozechner/pi-ai";
5
+ import type { Api, Model } from "@oh-my-pi/pi-ai";
6
+ import { completeSimple } from "@oh-my-pi/pi-ai";
7
7
  import titleSystemPrompt from "../prompts/title-system.md" with { type: "text" };
8
8
  import { logger } from "./logger";
9
9
  import type { ModelRegistry } from "./model-registry";
@@ -162,6 +162,7 @@ describe("createTools", () => {
162
162
  const expectedTools = [
163
163
  "ask",
164
164
  "bash",
165
+ "ssh",
165
166
  "edit",
166
167
  "find",
167
168
  "git",
@@ -26,6 +26,7 @@ export { createOutputTool, type OutputToolDetails } from "./output";
26
26
  export { createReadTool, type ReadToolDetails } from "./read";
27
27
  export { reportFindingTool, type SubmitReviewDetails } from "./review";
28
28
  export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook";
29
+ export { createSshTool, type SSHToolDetails } from "./ssh";
29
30
  export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index";
30
31
  export type { TruncationResult } from "./truncate";
31
32
  export { createWebFetchTool, type WebFetchToolDetails } from "./web-fetch";
@@ -68,6 +69,7 @@ import { createOutputTool } from "./output";
68
69
  import { createReadTool } from "./read";
69
70
  import { reportFindingTool } from "./review";
70
71
  import { createRulebookTool } from "./rulebook";
72
+ import { createSshTool } from "./ssh";
71
73
  import { createTaskTool } from "./task/index";
72
74
  import { createWebFetchTool } from "./web-fetch";
73
75
  import { createWebSearchTool } from "./web-search/index";
@@ -115,6 +117,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
115
117
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
116
118
  ask: createAskTool,
117
119
  bash: createBashTool,
120
+ ssh: createSshTool,
118
121
  edit: createEditTool,
119
122
  find: createFindTool,
120
123
  git: createGitTool,
@@ -6,8 +6,8 @@
6
6
 
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import type { TextContent } from "@mariozechner/pi-ai";
10
9
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
10
+ import type { TextContent } from "@oh-my-pi/pi-ai";
11
11
  import type { Component } from "@oh-my-pi/pi-tui";
12
12
  import { Text } from "@oh-my-pi/pi-tui";
13
13
  import { Type } from "@sinclair/typebox";
@@ -1,9 +1,11 @@
1
+ import { homedir } from "node:os";
1
2
  import path from "node:path";
2
- import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
3
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
4
5
  import type { Component } from "@oh-my-pi/pi-tui";
5
6
  import { Text } from "@oh-my-pi/pi-tui";
6
7
  import { Type } from "@sinclair/typebox";
8
+ import { CONFIG_DIR_NAME } from "../../config";
7
9
  import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
8
10
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
9
11
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
@@ -27,6 +29,13 @@ import {
27
29
  // Document types convertible via markitdown
28
30
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
29
31
 
32
+ // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
33
+ const REMOTE_MOUNT_PREFIX = path.join(homedir(), CONFIG_DIR_NAME, "remote") + path.sep;
34
+
35
+ function isRemoteMountPath(absolutePath: string): boolean {
36
+ return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
37
+ }
38
+
30
39
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
31
40
  const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
32
41
  const MAX_FUZZY_RESULTS = 5;
@@ -438,19 +447,23 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
438
447
  isDirectory = stat.isDirectory();
439
448
  } catch (error) {
440
449
  if (isNotFoundError(error)) {
441
- const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
442
450
  let message = `File not found: ${readPath}`;
443
451
 
444
- if (suggestions?.suggestions.length) {
445
- const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
446
- message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
447
- if (suggestions.truncated) {
448
- message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
452
+ // Skip fuzzy matching for remote mounts (sshfs) to avoid hangs
453
+ if (!isRemoteMountPath(absolutePath)) {
454
+ const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
455
+
456
+ if (suggestions?.suggestions.length) {
457
+ const scopeLabel = suggestions.scopeLabel ? ` in ${suggestions.scopeLabel}` : "";
458
+ message += `\n\nClosest matches${scopeLabel}:\n${suggestions.suggestions.map((match) => `- ${match}`).join("\n")}`;
459
+ if (suggestions.truncated) {
460
+ message += `\n[Search truncated to first ${MAX_FUZZY_CANDIDATES} paths. Refine the path if the match isn't listed.]`;
461
+ }
462
+ } else if (suggestions?.error) {
463
+ message += `\n\nFuzzy match failed: ${suggestions.error}`;
464
+ } else if (suggestions?.scopeLabel) {
465
+ message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
449
466
  }
450
- } else if (suggestions?.error) {
451
- message += `\n\nFuzzy match failed: ${suggestions.error}`;
452
- } else if (suggestions?.scopeLabel) {
453
- message += `\n\nNo similar paths found in ${suggestions.scopeLabel}.`;
454
467
  }
455
468
 
456
469
  throw new Error(message);
@@ -17,6 +17,7 @@ import { lspToolRenderer } from "./lsp/render";
17
17
  import { notebookToolRenderer } from "./notebook";
18
18
  import { outputToolRenderer } from "./output";
19
19
  import { readToolRenderer } from "./read";
20
+ import { sshToolRenderer } from "./ssh";
20
21
  import { taskToolRenderer } from "./task/render";
21
22
  import { webFetchToolRenderer } from "./web-fetch";
22
23
  import { webSearchToolRenderer } from "./web-search/render";
@@ -43,6 +44,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
43
44
  notebook: notebookToolRenderer as ToolRenderer,
44
45
  output: outputToolRenderer as ToolRenderer,
45
46
  read: readToolRenderer as ToolRenderer,
47
+ ssh: sshToolRenderer as ToolRenderer,
46
48
  task: taskToolRenderer as ToolRenderer,
47
49
  web_fetch: webFetchToolRenderer as ToolRenderer,
48
50
  web_search: webSearchToolRenderer as ToolRenderer,