@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
@@ -0,0 +1,190 @@
1
+ import { createWriteStream, type WriteStream } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Subprocess } from "bun";
5
+ import { nanoid } from "nanoid";
6
+ import stripAnsi from "strip-ansi";
7
+ import { killProcessTree, sanitizeBinaryOutput } from "../../utils/shell";
8
+ import { logger } from "../logger";
9
+ import { DEFAULT_MAX_BYTES, truncateTail } from "../tools/truncate";
10
+ import { ScopeSignal } from "../utils";
11
+ import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
12
+ import { hasSshfs, mountRemote } from "./sshfs-mount";
13
+
14
+ export interface SSHExecutorOptions {
15
+ /** Timeout in milliseconds */
16
+ timeout?: number;
17
+ /** Callback for streaming output chunks (already sanitized) */
18
+ onChunk?: (chunk: string) => void;
19
+ /** AbortSignal for cancellation */
20
+ signal?: AbortSignal;
21
+ /** Remote path to mount when sshfs is available */
22
+ remotePath?: string;
23
+ /** Wrap commands in a POSIX shell for compat mode */
24
+ compatEnabled?: boolean;
25
+ }
26
+
27
+ export interface SSHResult {
28
+ /** Combined stdout + stderr output (sanitized, possibly truncated) */
29
+ output: string;
30
+ /** Process exit code (undefined if killed/cancelled) */
31
+ exitCode: number | undefined;
32
+ /** Whether the command was cancelled via signal */
33
+ cancelled: boolean;
34
+ /** Whether the output was truncated */
35
+ truncated: boolean;
36
+ /** Path to temp file containing full output (if output exceeded truncation threshold) */
37
+ fullOutputPath?: string;
38
+ }
39
+
40
+ function createSanitizer(): TransformStream<Uint8Array, string> {
41
+ const decoder = new TextDecoder();
42
+ return new TransformStream({
43
+ transform(chunk, controller) {
44
+ const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(chunk, { stream: true }))).replace(/\r/g, "");
45
+ controller.enqueue(text);
46
+ },
47
+ });
48
+ }
49
+
50
+ function createOutputSink(
51
+ spillThreshold: number,
52
+ maxBuffer: number,
53
+ onChunk?: (text: string) => void,
54
+ ): WritableStream<string> & {
55
+ dump: (annotation?: string) => { output: string; truncated: boolean; fullOutputPath?: string };
56
+ } {
57
+ const chunks: string[] = [];
58
+ let chunkBytes = 0;
59
+ let totalBytes = 0;
60
+ let fullOutputPath: string | undefined;
61
+ let fullOutputStream: WriteStream | undefined;
62
+
63
+ const sink = new WritableStream<string>({
64
+ write(text) {
65
+ totalBytes += text.length;
66
+
67
+ if (totalBytes > spillThreshold && !fullOutputPath) {
68
+ fullOutputPath = join(tmpdir(), `omp-${nanoid()}.buffer`);
69
+ const ts = createWriteStream(fullOutputPath);
70
+ chunks.forEach((c) => {
71
+ ts.write(c);
72
+ });
73
+ fullOutputStream = ts;
74
+ }
75
+ fullOutputStream?.write(text);
76
+
77
+ chunks.push(text);
78
+ chunkBytes += text.length;
79
+ while (chunkBytes > maxBuffer && chunks.length > 1) {
80
+ chunkBytes -= chunks.shift()!.length;
81
+ }
82
+
83
+ onChunk?.(text);
84
+ },
85
+ close() {
86
+ fullOutputStream?.end();
87
+ },
88
+ });
89
+
90
+ return Object.assign(sink, {
91
+ dump(annotation?: string) {
92
+ if (annotation) {
93
+ chunks.push(`\n\n${annotation}`);
94
+ }
95
+ const full = chunks.join("");
96
+ const { content, truncated } = truncateTail(full);
97
+ return { output: truncated ? content : full, truncated, fullOutputPath };
98
+ },
99
+ });
100
+ }
101
+
102
+ function quoteForCompatShell(command: string): string {
103
+ if (command.length === 0) {
104
+ return "''";
105
+ }
106
+ const escaped = command.replace(/'/g, "'\\''");
107
+ return `'${escaped}'`;
108
+ }
109
+
110
+ function buildCompatCommand(shell: "bash" | "sh", command: string): string {
111
+ return `${shell} -c ${quoteForCompatShell(command)}`;
112
+ }
113
+
114
+ export async function executeSSH(
115
+ host: SSHConnectionTarget,
116
+ command: string,
117
+ options?: SSHExecutorOptions,
118
+ ): Promise<SSHResult> {
119
+ await ensureConnection(host);
120
+ if (hasSshfs()) {
121
+ try {
122
+ await mountRemote(host, options?.remotePath ?? "/");
123
+ } catch (err) {
124
+ logger.warn("SSHFS mount failed", { host: host.name, error: String(err) });
125
+ }
126
+ }
127
+
128
+ using signal = new ScopeSignal(options);
129
+
130
+ let resolvedCommand = command;
131
+ if (options?.compatEnabled) {
132
+ const info = await ensureHostInfo(host);
133
+ if (info.compatShell) {
134
+ resolvedCommand = buildCompatCommand(info.compatShell, command);
135
+ } else {
136
+ logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
137
+ }
138
+ }
139
+ const child: Subprocess = Bun.spawn(["ssh", ...buildRemoteCommand(host, resolvedCommand)], {
140
+ stdin: "ignore",
141
+ stdout: "pipe",
142
+ stderr: "pipe",
143
+ });
144
+
145
+ signal.catch(() => {
146
+ killProcessTree(child.pid);
147
+ });
148
+
149
+ const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
150
+
151
+ const writer = sink.getWriter();
152
+ try {
153
+ async function pumpStream(readable: ReadableStream<Uint8Array>) {
154
+ const reader = readable.pipeThrough(createSanitizer()).getReader();
155
+ try {
156
+ while (true) {
157
+ const { done, value } = await reader.read();
158
+ if (done) break;
159
+ await writer.write(value);
160
+ }
161
+ } finally {
162
+ reader.releaseLock();
163
+ }
164
+ }
165
+ await Promise.all([
166
+ pumpStream(child.stdout as ReadableStream<Uint8Array>),
167
+ pumpStream(child.stderr as ReadableStream<Uint8Array>),
168
+ ]);
169
+ } finally {
170
+ await writer.close();
171
+ }
172
+
173
+ const exitCode = await child.exited;
174
+ const cancelled = exitCode === null || (exitCode !== 0 && (options?.signal?.aborted ?? false));
175
+
176
+ if (signal.timedOut()) {
177
+ const secs = Math.round(options!.timeout! / 1000);
178
+ return {
179
+ exitCode: undefined,
180
+ cancelled: true,
181
+ ...sink.dump(`SSH command timed out after ${secs} seconds`),
182
+ };
183
+ }
184
+
185
+ return {
186
+ exitCode: cancelled ? undefined : exitCode,
187
+ cancelled,
188
+ ...sink.dump(),
189
+ };
190
+ }
@@ -0,0 +1,162 @@
1
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CONFIG_DIR_NAME } from "../../config";
5
+ import { logger } from "../logger";
6
+ import { getControlDir, getControlPathTemplate, type SSHConnectionTarget } from "./connection-manager";
7
+
8
+ const REMOTE_DIR = join(homedir(), CONFIG_DIR_NAME, "remote");
9
+ const CONTROL_DIR = getControlDir();
10
+ const CONTROL_PATH = getControlPathTemplate();
11
+
12
+ const mountedPaths = new Set<string>();
13
+
14
+ function ensureDir(path: string, mode = 0o700): void {
15
+ if (!existsSync(path)) {
16
+ mkdirSync(path, { recursive: true, mode });
17
+ }
18
+ try {
19
+ chmodSync(path, mode);
20
+ } catch (err) {
21
+ logger.debug("SSHFS dir chmod failed", { path, error: String(err) });
22
+ }
23
+ }
24
+
25
+ function decodeOutput(buffer?: Uint8Array): string {
26
+ if (!buffer || buffer.length === 0) return "";
27
+ return new TextDecoder().decode(buffer).trim();
28
+ }
29
+
30
+ function getMountName(host: SSHConnectionTarget): string {
31
+ const raw = (host.name ?? host.host).trim();
32
+ const sanitized = raw.replace(/[^a-zA-Z0-9._-]+/g, "_");
33
+ return sanitized.length > 0 ? sanitized : "remote";
34
+ }
35
+
36
+ function getMountPath(host: SSHConnectionTarget): string {
37
+ return join(REMOTE_DIR, getMountName(host));
38
+ }
39
+
40
+ function buildSshTarget(host: SSHConnectionTarget): string {
41
+ return host.username ? `${host.username}@${host.host}` : host.host;
42
+ }
43
+
44
+ function buildSshfsArgs(host: SSHConnectionTarget): string[] {
45
+ const args = [
46
+ "-o",
47
+ "reconnect",
48
+ "-o",
49
+ "ServerAliveInterval=15",
50
+ "-o",
51
+ "ServerAliveCountMax=3",
52
+ "-o",
53
+ "BatchMode=yes",
54
+ "-o",
55
+ "StrictHostKeyChecking=accept-new",
56
+ "-o",
57
+ "ControlMaster=auto",
58
+ "-o",
59
+ `ControlPath=${CONTROL_PATH}`,
60
+ "-o",
61
+ "ControlPersist=3600",
62
+ ];
63
+
64
+ if (host.port) {
65
+ args.push("-p", String(host.port));
66
+ }
67
+
68
+ if (host.keyPath) {
69
+ args.push("-o", `IdentityFile=${host.keyPath}`);
70
+ }
71
+
72
+ return args;
73
+ }
74
+
75
+ function unmountPath(path: string): boolean {
76
+ const fusermount = Bun.which("fusermount") ?? Bun.which("fusermount3");
77
+ if (fusermount) {
78
+ const result = Bun.spawnSync([fusermount, "-u", path], {
79
+ stdin: "ignore",
80
+ stdout: "ignore",
81
+ stderr: "pipe",
82
+ });
83
+ if (result.exitCode === 0) return true;
84
+ }
85
+
86
+ const umount = Bun.which("umount");
87
+ if (!umount) return false;
88
+ const result = Bun.spawnSync([umount, path], {
89
+ stdin: "ignore",
90
+ stdout: "ignore",
91
+ stderr: "pipe",
92
+ });
93
+ return result.exitCode === 0;
94
+ }
95
+
96
+ export function hasSshfs(): boolean {
97
+ return Bun.which("sshfs") !== null;
98
+ }
99
+
100
+ export function isMounted(path: string): boolean {
101
+ const mountpoint = Bun.which("mountpoint");
102
+ if (!mountpoint) return false;
103
+ const result = Bun.spawnSync([mountpoint, "-q", path], {
104
+ stdin: "ignore",
105
+ stdout: "ignore",
106
+ stderr: "ignore",
107
+ });
108
+ return result.exitCode === 0;
109
+ }
110
+
111
+ export async function mountRemote(host: SSHConnectionTarget, remotePath = "/"): Promise<string | undefined> {
112
+ if (!hasSshfs()) return undefined;
113
+
114
+ ensureDir(REMOTE_DIR);
115
+ ensureDir(CONTROL_DIR);
116
+
117
+ const mountPath = getMountPath(host);
118
+ ensureDir(mountPath);
119
+
120
+ if (isMounted(mountPath)) {
121
+ mountedPaths.add(mountPath);
122
+ return mountPath;
123
+ }
124
+
125
+ const target = `${buildSshTarget(host)}:${remotePath}`;
126
+ const result = Bun.spawnSync(["sshfs", ...buildSshfsArgs(host), target, mountPath], {
127
+ stdin: "ignore",
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+
132
+ if (result.exitCode !== 0) {
133
+ const detail = decodeOutput(result.stderr);
134
+ const suffix = detail ? `: ${detail}` : "";
135
+ throw new Error(`Failed to mount ${target}${suffix}`);
136
+ }
137
+
138
+ mountedPaths.add(mountPath);
139
+ return mountPath;
140
+ }
141
+
142
+ export async function unmountRemote(host: SSHConnectionTarget): Promise<boolean> {
143
+ const mountPath = getMountPath(host);
144
+ if (!isMounted(mountPath)) {
145
+ mountedPaths.delete(mountPath);
146
+ return false;
147
+ }
148
+
149
+ const success = unmountPath(mountPath);
150
+ if (success) {
151
+ mountedPaths.delete(mountPath);
152
+ }
153
+
154
+ return success;
155
+ }
156
+
157
+ export async function unmountAll(): Promise<void> {
158
+ for (const mountPath of Array.from(mountedPaths)) {
159
+ unmountPath(mountPath);
160
+ }
161
+ mountedPaths.clear();
162
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Backwards-compatible re-export for SSH execution.
3
+ */
4
+
5
+ export { executeSSH, type SSHExecutorOptions, type SSHResult } from "./ssh/ssh-executor";