@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
@@ -5,8 +5,8 @@
5
5
  * and provides a transformer to convert them to LLM-compatible messages.
6
6
  */
7
7
 
8
- import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
9
8
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
9
+ import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
10
10
 
11
11
  export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
12
12
 
@@ -11,7 +11,7 @@ import {
11
11
  type KnownProvider,
12
12
  type Model,
13
13
  normalizeDomain,
14
- } from "@mariozechner/pi-ai";
14
+ } from "@oh-my-pi/pi-ai";
15
15
  import { type Static, Type } from "@sinclair/typebox";
16
16
  import AjvModule from "ajv";
17
17
  import type { AuthStorage } from "./auth-storage";
@@ -2,8 +2,8 @@
2
2
  * Model resolution, scoping, and initial selection
3
3
  */
4
4
 
5
- import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
6
5
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
+ import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
7
7
  import chalk from "chalk";
8
8
  import { minimatch } from "minimatch";
9
9
  import { isValidThinkingLevel } from "../cli/args";
@@ -25,6 +25,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
25
25
  cerebras: "zai-glm-4.6",
26
26
  zai: "glm-4.6",
27
27
  mistral: "devstral-medium-latest",
28
+ opencode: "claude-sonnet-4-5",
28
29
  };
29
30
 
30
31
  export interface ScopedModel {
@@ -79,7 +80,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
79
80
  const provider = modelPattern.substring(0, slashIndex);
80
81
  const modelId = modelPattern.substring(slashIndex + 1);
81
82
  const providerMatch = availableModels.find(
82
- (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase()
83
+ (m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
83
84
  );
84
85
  if (providerMatch) {
85
86
  return providerMatch;
@@ -97,7 +98,7 @@ function tryMatchModel(modelPattern: string, availableModels: Model<Api>[]): Mod
97
98
  const matches = availableModels.filter(
98
99
  (m) =>
99
100
  m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
100
- m.name?.toLowerCase().includes(modelPattern.toLowerCase())
101
+ m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
101
102
  );
102
103
 
103
104
  if (matches.length === 0) {
@@ -351,7 +352,7 @@ export async function restoreModelFromSession(
351
352
  savedModelId: string,
352
353
  currentModel: Model<Api> | undefined,
353
354
  shouldPrintMessages: boolean,
354
- modelRegistry: ModelRegistry
355
+ modelRegistry: ModelRegistry,
355
356
  ): Promise<{ model: Model<Api> | undefined; fallbackMessage: string | undefined }> {
356
357
  const restoredModel = modelRegistry.find(savedProvider, savedModelId);
357
358
 
@@ -427,7 +428,7 @@ export async function restoreModelFromSession(
427
428
  */
428
429
  export async function findSmolModel(
429
430
  modelRegistry: ModelRegistry,
430
- savedModel?: string
431
+ savedModel?: string,
431
432
  ): Promise<Model<Api> | undefined> {
432
433
  const availableModels = modelRegistry.getAvailable();
433
434
  if (availableModels.length === 0) return undefined;
@@ -470,7 +471,7 @@ export async function findSmolModel(
470
471
  */
471
472
  export async function findSlowModel(
472
473
  modelRegistry: ModelRegistry,
473
- savedModel?: string
474
+ savedModel?: string,
474
475
  ): Promise<Model<Api> | undefined> {
475
476
  const availableModels = modelRegistry.getAvailable();
476
477
  if (availableModels.length === 0) return undefined;
package/src/core/sdk.ts CHANGED
@@ -27,8 +27,8 @@
27
27
  */
28
28
 
29
29
  import { join } from "node:path";
30
- import type { Model } from "@mariozechner/pi-ai";
31
30
  import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
31
+ import type { Model } from "@oh-my-pi/pi-ai";
32
32
  import type { Component } from "@oh-my-pi/pi-tui";
33
33
  import chalk from "chalk";
34
34
  // Import discovery to register all providers on startup
@@ -37,6 +37,7 @@ import { loadSync as loadCapability } from "../capability/index";
37
37
  import { type Rule, ruleCapability } from "../capability/rule";
38
38
  import { getAgentDir, getConfigDirPaths } from "../config";
39
39
  import { initializeWithSettings } from "../discovery";
40
+ import { registerAsyncCleanup } from "../modes/cleanup";
40
41
  import { AgentSession } from "./agent-session";
41
42
  import { AuthStorage } from "./auth-storage";
42
43
  import {
@@ -67,6 +68,8 @@ import { SessionManager } from "./session-manager";
67
68
  import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager";
68
69
  import { loadSkills as loadSkillsInternal, type Skill } from "./skills";
69
70
  import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands";
71
+ import { closeAllConnections } from "./ssh/connection-manager";
72
+ import { unmountAll } from "./ssh/sshfs-mount";
70
73
  import {
71
74
  buildSystemPrompt as buildSystemPromptInternal,
72
75
  loadProjectContextFiles as loadContextFilesInternal,
@@ -83,6 +86,7 @@ import {
83
86
  createGrepTool,
84
87
  createLsTool,
85
88
  createReadTool,
89
+ createSshTool,
86
90
  createTools,
87
91
  createWriteTool,
88
92
  filterRulebookRules,
@@ -204,6 +208,7 @@ export {
204
208
  // Individual tool factories (for custom usage)
205
209
  createReadTool,
206
210
  createBashTool,
211
+ createSshTool,
207
212
  createEditTool,
208
213
  createWriteTool,
209
214
  createGrepTool,
@@ -399,6 +404,23 @@ function isCustomTool(tool: CustomTool | ToolDefinition): tool is CustomTool {
399
404
 
400
405
  const TOOL_DEFINITION_MARKER = Symbol("__isToolDefinition");
401
406
 
407
+ let sshCleanupRegistered = false;
408
+
409
+ async function cleanupSshResources(): Promise<void> {
410
+ const results = await Promise.allSettled([closeAllConnections(), unmountAll()]);
411
+ for (const result of results) {
412
+ if (result.status === "rejected") {
413
+ logger.warn("SSH cleanup failed", { error: String(result.reason) });
414
+ }
415
+ }
416
+ }
417
+
418
+ function registerSshCleanup(): void {
419
+ if (sshCleanupRegistered) return;
420
+ sshCleanupRegistered = true;
421
+ registerAsyncCleanup(() => cleanupSshResources());
422
+ }
423
+
402
424
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
403
425
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
404
426
  name: tool.name,
@@ -471,7 +493,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
471
493
  * const { session } = await createAgentSession();
472
494
  *
473
495
  * // With explicit model
474
- * import { getModel } from '@mariozechner/pi-ai';
496
+ * import { getModel } from '@oh-my-pi/pi-ai';
475
497
  * const { session } = await createAgentSession({
476
498
  * model: getModel('anthropic', 'claude-opus-4-5'),
477
499
  * thinkingLevel: 'high',
@@ -498,6 +520,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
498
520
  const agentDir = options.agentDir ?? getDefaultAgentDir();
499
521
  const eventBus = options.eventBus ?? createEventBus();
500
522
 
523
+ registerSshCleanup();
524
+
501
525
  // Use provided or create AuthStorage and ModelRegistry
502
526
  const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir));
503
527
  const modelRegistry = options.modelRegistry ?? (await discoverModels(authStorage, agentDir));
@@ -1,6 +1,6 @@
1
1
  import { basename, join, resolve } from "node:path";
2
- import type { ImageContent, Message, TextContent, Usage } from "@mariozechner/pi-ai";
3
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
+ import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
4
4
  import { nanoid } from "nanoid";
5
5
  import { getAgentDir as getDefaultAgentDir } from "../config";
6
6
  import { resizeImage } from "../utils/image-resize";
@@ -0,0 +1,466 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } 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
+
7
+ export interface SSHConnectionTarget {
8
+ name: string;
9
+ host: string;
10
+ username?: string;
11
+ port?: number;
12
+ keyPath?: string;
13
+ compat?: boolean;
14
+ }
15
+
16
+ export type SSHHostOs = "windows" | "linux" | "macos" | "unknown";
17
+ export type SSHHostShell = "cmd" | "powershell" | "bash" | "zsh" | "sh" | "unknown";
18
+
19
+ export interface SSHHostInfo {
20
+ version: number;
21
+ os: SSHHostOs;
22
+ shell: SSHHostShell;
23
+ compatShell?: "bash" | "sh";
24
+ compatEnabled: boolean;
25
+ }
26
+
27
+ const CONTROL_DIR = join(homedir(), CONFIG_DIR_NAME, "ssh-control");
28
+ const CONTROL_PATH = join(CONTROL_DIR, "%h.sock");
29
+ const HOST_INFO_DIR = join(homedir(), CONFIG_DIR_NAME, "remote-host");
30
+ const HOST_INFO_VERSION = 2;
31
+
32
+ const activeHosts = new Map<string, SSHConnectionTarget>();
33
+ const pendingConnections = new Map<string, Promise<void>>();
34
+ const hostInfoCache = new Map<string, SSHHostInfo>();
35
+
36
+ function ensureControlDir(): void {
37
+ if (!existsSync(CONTROL_DIR)) {
38
+ mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 });
39
+ }
40
+ try {
41
+ chmodSync(CONTROL_DIR, 0o700);
42
+ } catch (err) {
43
+ logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) });
44
+ }
45
+ }
46
+
47
+ function ensureHostInfoDir(): void {
48
+ if (!existsSync(HOST_INFO_DIR)) {
49
+ mkdirSync(HOST_INFO_DIR, { recursive: true, mode: 0o700 });
50
+ }
51
+ try {
52
+ chmodSync(HOST_INFO_DIR, 0o700);
53
+ } catch (err) {
54
+ logger.debug("SSH host info dir chmod failed", { path: HOST_INFO_DIR, error: String(err) });
55
+ }
56
+ }
57
+
58
+ function sanitizeHostName(name: string): string {
59
+ const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "_");
60
+ return sanitized.length > 0 ? sanitized : "host";
61
+ }
62
+
63
+ function getHostInfoPath(name: string): string {
64
+ return join(HOST_INFO_DIR, `${sanitizeHostName(name)}.json`);
65
+ }
66
+
67
+ function validateKeyPermissions(keyPath?: string): void {
68
+ if (!keyPath) return;
69
+ if (!existsSync(keyPath)) {
70
+ throw new Error(`SSH key not found: ${keyPath}`);
71
+ }
72
+ const stats = statSync(keyPath);
73
+ if (!stats.isFile()) {
74
+ throw new Error(`SSH key is not a file: ${keyPath}`);
75
+ }
76
+ const mode = stats.mode & 0o777;
77
+ if ((mode & 0o077) !== 0) {
78
+ throw new Error(`SSH key permissions must be 600 or stricter: ${keyPath}`);
79
+ }
80
+ }
81
+
82
+ function buildSshTarget(host: SSHConnectionTarget): string {
83
+ return host.username ? `${host.username}@${host.host}` : host.host;
84
+ }
85
+
86
+ function buildCommonArgs(host: SSHConnectionTarget): string[] {
87
+ const args = [
88
+ "-o",
89
+ "ControlMaster=auto",
90
+ "-o",
91
+ `ControlPath=${CONTROL_PATH}`,
92
+ "-o",
93
+ "ControlPersist=3600",
94
+ "-o",
95
+ "BatchMode=yes",
96
+ "-o",
97
+ "StrictHostKeyChecking=accept-new",
98
+ ];
99
+
100
+ if (host.port) {
101
+ args.push("-p", String(host.port));
102
+ }
103
+ if (host.keyPath) {
104
+ args.push("-i", host.keyPath);
105
+ }
106
+
107
+ return args;
108
+ }
109
+
110
+ function decodeOutput(buffer?: Uint8Array): string {
111
+ if (!buffer || buffer.length === 0) return "";
112
+ return new TextDecoder().decode(buffer).trim();
113
+ }
114
+
115
+ function runSshSync(args: string[]): { exitCode: number | null; stderr: string } {
116
+ const result = Bun.spawnSync(["ssh", ...args], {
117
+ stdin: "ignore",
118
+ stdout: "ignore",
119
+ stderr: "pipe",
120
+ });
121
+
122
+ return { exitCode: result.exitCode, stderr: decodeOutput(result.stderr) };
123
+ }
124
+
125
+ function runSshCaptureSync(args: string[]): { exitCode: number | null; stdout: string; stderr: string } {
126
+ const result = Bun.spawnSync(["ssh", ...args], {
127
+ stdin: "ignore",
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+
132
+ return {
133
+ exitCode: result.exitCode,
134
+ stdout: decodeOutput(result.stdout),
135
+ stderr: decodeOutput(result.stderr),
136
+ };
137
+ }
138
+
139
+ function ensureSshBinary(): void {
140
+ if (!Bun.which("ssh")) {
141
+ throw new Error("ssh binary not found on PATH");
142
+ }
143
+ }
144
+
145
+ function parseOs(value: unknown): SSHHostOs | null {
146
+ if (typeof value !== "string") return null;
147
+ const normalized = value.trim().toLowerCase();
148
+ switch (normalized) {
149
+ case "windows":
150
+ return "windows";
151
+ case "linux":
152
+ return "linux";
153
+ case "macos":
154
+ case "darwin":
155
+ return "macos";
156
+ case "unknown":
157
+ return "unknown";
158
+ default:
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function parseShell(value: unknown): SSHHostShell | null {
164
+ if (typeof value !== "string") return null;
165
+ const normalized = value.trim().toLowerCase();
166
+ if (!normalized) return "unknown";
167
+ if (normalized.includes("bash")) return "bash";
168
+ if (normalized.includes("zsh")) return "zsh";
169
+ if (normalized.includes("pwsh") || normalized.includes("powershell")) return "powershell";
170
+ if (normalized.includes("cmd.exe") || normalized === "cmd") return "cmd";
171
+ if (normalized.endsWith("sh") || normalized.includes("/sh")) return "sh";
172
+ return "unknown";
173
+ }
174
+
175
+ function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
176
+ if (value === "bash" || value === "sh") return value;
177
+ return undefined;
178
+ }
179
+
180
+ function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
181
+ const compatShell =
182
+ info.compatShell ??
183
+ (info.os === "windows" && info.shell === "bash"
184
+ ? "bash"
185
+ : info.os === "windows" && info.shell === "sh"
186
+ ? "sh"
187
+ : undefined);
188
+ const compatEnabled = host.compat === false ? false : info.os === "windows" && compatShell !== undefined;
189
+ if (host.compat === true && !compatShell) {
190
+ logger.warn("SSH compat requested but no compatible shell detected", {
191
+ host: host.name,
192
+ shell: info.shell,
193
+ });
194
+ }
195
+ return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
196
+ }
197
+
198
+ function parseHostInfo(value: unknown): SSHHostInfo | null {
199
+ if (!value || typeof value !== "object") return null;
200
+ const record = value as Record<string, unknown>;
201
+ const os = parseOs(record.os) ?? "unknown";
202
+ const shell = parseShell(record.shell) ?? "unknown";
203
+ const compatShell = parseCompatShell(record.compatShell);
204
+ const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
205
+ const version = typeof record.version === "number" ? record.version : 0;
206
+ return {
207
+ version,
208
+ os,
209
+ shell,
210
+ compatShell,
211
+ compatEnabled,
212
+ };
213
+ }
214
+
215
+ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): boolean {
216
+ if (info.version !== HOST_INFO_VERSION) return true;
217
+ if (info.os === "unknown") return true;
218
+ if (info.os !== "windows" && info.compatEnabled) return true;
219
+ if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
220
+ if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
221
+ if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
222
+ return false;
223
+ }
224
+
225
+ function loadHostInfoFromDisk(host: SSHConnectionTarget): SSHHostInfo | undefined {
226
+ const path = getHostInfoPath(host.name);
227
+ if (!existsSync(path)) return undefined;
228
+ try {
229
+ const raw = readFileSync(path, "utf-8");
230
+ const parsed = parseHostInfo(JSON.parse(raw));
231
+ if (!parsed) return undefined;
232
+ const resolved = applyCompatOverride(host, parsed);
233
+ hostInfoCache.set(host.name, resolved);
234
+ return resolved;
235
+ } catch (err) {
236
+ logger.warn("Failed to load SSH host info", { host: host.name, error: String(err) });
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ function loadHostInfoFromDiskByName(hostName: string): SSHHostInfo | undefined {
242
+ const path = getHostInfoPath(hostName);
243
+ if (!existsSync(path)) return undefined;
244
+ try {
245
+ const raw = readFileSync(path, "utf-8");
246
+ const parsed = parseHostInfo(JSON.parse(raw));
247
+ if (!parsed) return undefined;
248
+ return parsed;
249
+ } catch (err) {
250
+ logger.warn("Failed to load SSH host info", { host: hostName, error: String(err) });
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Promise<void> {
256
+ try {
257
+ ensureHostInfoDir();
258
+ const path = getHostInfoPath(host.name);
259
+ const payload = { ...info, version: HOST_INFO_VERSION };
260
+ hostInfoCache.set(host.name, payload);
261
+ await Bun.write(path, JSON.stringify(payload, null, 2), { createPath: true });
262
+ } catch (err) {
263
+ logger.warn("Failed to persist SSH host info", { host: host.name, error: String(err) });
264
+ }
265
+ }
266
+
267
+ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
268
+ const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"';
269
+ const result = runSshCaptureSync(buildRemoteCommand(host, command));
270
+ if (result.exitCode !== 0 && !result.stdout) {
271
+ logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
272
+ const fallback: SSHHostInfo = {
273
+ version: HOST_INFO_VERSION,
274
+ os: "unknown",
275
+ shell: "unknown",
276
+ compatShell: undefined,
277
+ compatEnabled: false,
278
+ };
279
+ hostInfoCache.set(host.name, fallback);
280
+ return fallback;
281
+ }
282
+
283
+ const output = (result.stdout || result.stderr).split("\n")[0]?.trim() ?? "";
284
+ const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
285
+ const ostype = rawOs.trim();
286
+ const shellRaw = rawShell.trim();
287
+ const bashVersion = rawBash.trim();
288
+ const outputLower = output.toLowerCase();
289
+ const osLower = ostype.toLowerCase();
290
+ const shellLower = shellRaw.toLowerCase();
291
+ const unexpandedPosixVars =
292
+ output.includes("$OSTYPE") || output.includes("$SHELL") || output.includes("$BASH_VERSION");
293
+ const windowsDetected =
294
+ osLower.includes("windows") ||
295
+ osLower.includes("msys") ||
296
+ osLower.includes("cygwin") ||
297
+ osLower.includes("mingw") ||
298
+ outputLower.includes("windows_nt") ||
299
+ outputLower.includes("comspec") ||
300
+ shellLower.includes("cmd") ||
301
+ shellLower.includes("powershell") ||
302
+ unexpandedPosixVars ||
303
+ output.includes("%OS%");
304
+
305
+ let os: SSHHostOs = "unknown";
306
+ if (windowsDetected) {
307
+ os = "windows";
308
+ } else if (osLower.includes("darwin")) {
309
+ os = "macos";
310
+ } else if (osLower.includes("linux") || osLower.includes("gnu")) {
311
+ os = "linux";
312
+ }
313
+
314
+ let shell: SSHHostShell = "unknown";
315
+ if (shellLower.includes("bash")) {
316
+ shell = "bash";
317
+ } else if (shellLower.includes("zsh")) {
318
+ shell = "zsh";
319
+ } else if (shellLower.includes("pwsh") || shellLower.includes("powershell")) {
320
+ shell = "powershell";
321
+ } else if (shellLower.includes("cmd.exe") || shellLower === "cmd") {
322
+ shell = "cmd";
323
+ } else if (shellLower.endsWith("sh") || shellLower.includes("/sh")) {
324
+ shell = "sh";
325
+ } else if (os === "windows" && !shellLower) {
326
+ shell = "cmd";
327
+ }
328
+
329
+ const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
330
+ let compatShell: SSHHostInfo["compatShell"];
331
+ if (os === "windows" && host.compat !== false) {
332
+ const bashProbe = runSshCaptureSync(buildRemoteCommand(host, 'bash -lc "echo OMP_BASH_OK"'));
333
+ if (bashProbe.exitCode === 0 && bashProbe.stdout.includes("OMP_BASH_OK")) {
334
+ compatShell = "bash";
335
+ } else {
336
+ const shProbe = runSshCaptureSync(buildRemoteCommand(host, 'sh -lc "echo OMP_SH_OK"'));
337
+ if (shProbe.exitCode === 0 && shProbe.stdout.includes("OMP_SH_OK")) {
338
+ compatShell = "sh";
339
+ }
340
+ }
341
+ } else if (os === "windows" && hasBash) {
342
+ compatShell = "bash";
343
+ } else if (os === "windows" && shell === "sh") {
344
+ compatShell = "sh";
345
+ }
346
+ const compatEnabled = host.compat === false ? false : os === "windows" && compatShell !== undefined;
347
+
348
+ const info: SSHHostInfo = applyCompatOverride(host, {
349
+ version: HOST_INFO_VERSION,
350
+ os,
351
+ shell,
352
+ compatShell,
353
+ compatEnabled,
354
+ });
355
+
356
+ hostInfoCache.set(host.name, info);
357
+ await persistHostInfo(host, info);
358
+ return info;
359
+ }
360
+
361
+ export function getHostInfo(hostName: string): SSHHostInfo | undefined {
362
+ return hostInfoCache.get(hostName) ?? loadHostInfoFromDiskByName(hostName);
363
+ }
364
+
365
+ export function getHostInfoForHost(host: SSHConnectionTarget): SSHHostInfo | undefined {
366
+ const cached = hostInfoCache.get(host.name);
367
+ if (cached) {
368
+ const resolved = applyCompatOverride(host, cached);
369
+ if (resolved !== cached) hostInfoCache.set(host.name, resolved);
370
+ return resolved;
371
+ }
372
+ return loadHostInfoFromDisk(host);
373
+ }
374
+
375
+ export async function ensureHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
376
+ const cached = hostInfoCache.get(host.name);
377
+ if (cached) {
378
+ const resolved = applyCompatOverride(host, cached);
379
+ hostInfoCache.set(host.name, resolved);
380
+ if (!shouldRefreshHostInfo(host, resolved)) return resolved;
381
+ }
382
+ const fromDisk = loadHostInfoFromDisk(host);
383
+ if (fromDisk && !shouldRefreshHostInfo(host, fromDisk)) return fromDisk;
384
+ await ensureConnection(host);
385
+ const current = hostInfoCache.get(host.name);
386
+ if (current && !shouldRefreshHostInfo(host, current)) return current;
387
+ return probeHostInfo(host);
388
+ }
389
+
390
+ export function buildRemoteCommand(host: SSHConnectionTarget, command: string): string[] {
391
+ validateKeyPermissions(host.keyPath);
392
+ return [...buildCommonArgs(host), buildSshTarget(host), command];
393
+ }
394
+
395
+ export async function ensureConnection(host: SSHConnectionTarget): Promise<void> {
396
+ const key = host.name;
397
+ const pending = pendingConnections.get(key);
398
+ if (pending) {
399
+ await pending;
400
+ return;
401
+ }
402
+
403
+ const promise = (async () => {
404
+ ensureSshBinary();
405
+ ensureControlDir();
406
+ validateKeyPermissions(host.keyPath);
407
+
408
+ const target = buildSshTarget(host);
409
+ const check = runSshSync(["-O", "check", ...buildCommonArgs(host), target]);
410
+ if (check.exitCode === 0) {
411
+ activeHosts.set(key, host);
412
+ if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
413
+ await probeHostInfo(host);
414
+ }
415
+ return;
416
+ }
417
+
418
+ const start = runSshSync(["-M", "-N", "-f", ...buildCommonArgs(host), target]);
419
+ if (start.exitCode !== 0) {
420
+ const detail = start.stderr ? `: ${start.stderr}` : "";
421
+ throw new Error(`Failed to start SSH master for ${target}${detail}`);
422
+ }
423
+
424
+ activeHosts.set(key, host);
425
+ if (!hostInfoCache.has(key) && !loadHostInfoFromDisk(host)) {
426
+ await probeHostInfo(host);
427
+ }
428
+ })();
429
+
430
+ pendingConnections.set(key, promise);
431
+ try {
432
+ await promise;
433
+ } finally {
434
+ pendingConnections.delete(key);
435
+ }
436
+ }
437
+
438
+ function closeConnectionInternal(host: SSHConnectionTarget): void {
439
+ const target = buildSshTarget(host);
440
+ runSshSync(["-O", "exit", ...buildCommonArgs(host), target]);
441
+ }
442
+
443
+ export async function closeConnection(hostName: string): Promise<void> {
444
+ const host = activeHosts.get(hostName);
445
+ if (!host) {
446
+ closeConnectionInternal({ name: hostName, host: hostName });
447
+ return;
448
+ }
449
+ closeConnectionInternal(host);
450
+ activeHosts.delete(hostName);
451
+ }
452
+
453
+ export async function closeAllConnections(): Promise<void> {
454
+ for (const [name, host] of Array.from(activeHosts.entries())) {
455
+ closeConnectionInternal(host);
456
+ activeHosts.delete(name);
457
+ }
458
+ }
459
+
460
+ export function getControlPathTemplate(): string {
461
+ return CONTROL_PATH;
462
+ }
463
+
464
+ export function getControlDir(): string {
465
+ return CONTROL_DIR;
466
+ }