@oh-my-pi/pi-coding-agent 3.32.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.
- package/CHANGELOG.md +49 -9
- package/README.md +12 -0
- package/docs/custom-tools.md +1 -1
- package/docs/extensions.md +4 -4
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +4 -8
- package/examples/custom-tools/README.md +2 -2
- package/examples/extensions/README.md +1 -1
- package/examples/extensions/todo.ts +1 -1
- package/examples/hooks/custom-compaction.ts +4 -2
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/README.md +1 -1
- package/package.json +5 -5
- package/src/capability/ssh.ts +42 -0
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +21 -6
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -1
- package/src/core/file-mentions.ts +147 -5
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/index.ts +11 -0
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +9 -4
- package/src/core/sdk.ts +26 -2
- package/src/core/session-manager.ts +3 -2
- package/src/core/settings-manager.ts +70 -0
- package/src/core/ssh/connection-manager.ts +466 -0
- package/src/core/ssh/ssh-executor.ts +190 -0
- package/src/core/ssh/sshfs-mount.ts +162 -0
- package/src/core/ssh-executor.ts +5 -0
- package/src/core/system-prompt.ts +424 -1
- package/src/core/title-generator.ts +109 -55
- package/src/core/tools/index.test.ts +1 -0
- package/src/core/tools/index.ts +3 -0
- package/src/core/tools/output.ts +37 -2
- package/src/core/tools/read.ts +24 -11
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/ssh.ts +302 -0
- package/src/core/tools/task/index.ts +1 -1
- package/src/core/tools/task/render.ts +10 -16
- package/src/core/tools/task/types.ts +1 -1
- package/src/core/tools/task/worker.ts +1 -1
- package/src/core/voice.ts +1 -1
- package/src/discovery/index.ts +3 -0
- package/src/discovery/ssh.ts +162 -0
- package/src/main.ts +2 -1
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/bash-execution.ts +9 -10
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tree-selector.ts +9 -12
- package/src/modes/interactive/interactive-mode.ts +5 -2
- package/src/modes/interactive/theme/theme.ts +2 -2
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/tools/ssh.md +74 -0
- package/src/utils/image-resize.ts +1 -1
|
@@ -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
|
+
}
|
|
@@ -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,
|