@oh-my-pi/pi-coding-agent 3.33.0 → 3.35.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 (72) hide show
  1. package/CHANGELOG.md +57 -8
  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 +214 -31
  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 +33 -4
  34. package/src/core/session-manager.ts +16 -1
  35. package/src/core/settings-manager.ts +20 -6
  36. package/src/core/ssh/connection-manager.ts +466 -0
  37. package/src/core/ssh/ssh-executor.ts +190 -0
  38. package/src/core/ssh/sshfs-mount.ts +162 -0
  39. package/src/core/ssh-executor.ts +5 -0
  40. package/src/core/system-prompt.ts +424 -1
  41. package/src/core/title-generator.ts +2 -2
  42. package/src/core/tools/edit.ts +1 -0
  43. package/src/core/tools/grep.ts +1 -1
  44. package/src/core/tools/index.test.ts +1 -0
  45. package/src/core/tools/index.ts +5 -0
  46. package/src/core/tools/output.ts +1 -1
  47. package/src/core/tools/read.ts +24 -11
  48. package/src/core/tools/renderers.ts +3 -0
  49. package/src/core/tools/ssh.ts +302 -0
  50. package/src/core/tools/task/index.ts +11 -2
  51. package/src/core/tools/task/model-resolver.ts +5 -4
  52. package/src/core/tools/task/types.ts +1 -1
  53. package/src/core/tools/task/worker.ts +1 -1
  54. package/src/core/voice.ts +1 -1
  55. package/src/discovery/index.ts +3 -0
  56. package/src/discovery/ssh.ts +162 -0
  57. package/src/main.ts +4 -1
  58. package/src/modes/interactive/components/assistant-message.ts +1 -1
  59. package/src/modes/interactive/components/custom-message.ts +1 -1
  60. package/src/modes/interactive/components/footer.ts +1 -1
  61. package/src/modes/interactive/components/hook-message.ts +1 -1
  62. package/src/modes/interactive/components/model-selector.ts +1 -1
  63. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  64. package/src/modes/interactive/components/status-line.ts +1 -1
  65. package/src/modes/interactive/components/tool-execution.ts +15 -12
  66. package/src/modes/interactive/interactive-mode.ts +43 -9
  67. package/src/modes/print-mode.ts +1 -1
  68. package/src/modes/rpc/rpc-client.ts +1 -1
  69. package/src/modes/rpc/rpc-types.ts +1 -1
  70. package/src/prompts/system-prompt.md +4 -0
  71. package/src/prompts/tools/ssh.md +74 -0
  72. package/src/utils/image-resize.ts +1 -1
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));
@@ -609,6 +633,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
609
633
  const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
610
634
  time("discoverContextFiles");
611
635
 
636
+ let agent: Agent;
637
+ let session: AgentSession;
638
+
612
639
  const toolSession: ToolSession = {
613
640
  cwd,
614
641
  hasUI: options.hasUI ?? false,
@@ -619,6 +646,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
619
646
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
620
647
  getSessionSpawns: () => options.spawns ?? "*",
621
648
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
649
+ getActiveModelString: () => {
650
+ const activeModel = agent?.state.model;
651
+ return activeModel ? formatModelString(activeModel) : undefined;
652
+ },
622
653
  settings: settingsManager,
623
654
  };
624
655
 
@@ -758,8 +789,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
758
789
  extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry);
759
790
  }
760
791
 
761
- let agent: Agent;
762
- let session: AgentSession;
763
792
  const getSessionContext = () => ({
764
793
  sessionManager,
765
794
  modelRegistry,
@@ -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";
@@ -1338,6 +1338,21 @@ export class SessionManager {
1338
1338
  return this.leafId ? this.byId.get(this.leafId) : undefined;
1339
1339
  }
1340
1340
 
1341
+ /**
1342
+ * Get the most recent model role from the current session path.
1343
+ * Returns undefined if no model change has been recorded.
1344
+ */
1345
+ getLastModelChangeRole(): string | undefined {
1346
+ let current = this.getLeafEntry();
1347
+ while (current) {
1348
+ if (current.type === "model_change") {
1349
+ return current.role ?? "default";
1350
+ }
1351
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
1352
+ }
1353
+ return undefined;
1354
+ }
1355
+
1341
1356
  getEntry(id: string): SessionEntry | undefined {
1342
1357
  return this.byId.get(id);
1343
1358
  }
@@ -371,7 +371,8 @@ export class SettingsManager {
371
371
  private settingsPath: string | null;
372
372
  private cwd: string | null;
373
373
  private globalSettings: Settings;
374
- private settings: Settings;
374
+ private overrides: Settings;
375
+ private settings!: Settings;
375
376
  private persist: boolean;
376
377
 
377
378
  private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) {
@@ -379,8 +380,8 @@ export class SettingsManager {
379
380
  this.cwd = cwd;
380
381
  this.persist = persist;
381
382
  this.globalSettings = initialSettings;
382
- const projectSettings = this.loadProjectSettings();
383
- this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
383
+ this.overrides = {};
384
+ this.rebuildSettings();
384
385
 
385
386
  // Apply environment variables from settings
386
387
  this.applyEnvironmentVariables();
@@ -474,9 +475,17 @@ export class SettingsManager {
474
475
  return SettingsManager.migrateSettings(merged as Record<string, unknown>);
475
476
  }
476
477
 
478
+ private rebuildSettings(projectSettings?: Settings): void {
479
+ const resolvedProjectSettings = projectSettings ?? this.loadProjectSettings();
480
+ this.settings = normalizeSettings(
481
+ deepMergeSettings(deepMergeSettings(this.globalSettings, resolvedProjectSettings), this.overrides),
482
+ );
483
+ }
484
+
477
485
  /** Apply additional overrides on top of current settings */
478
486
  applyOverrides(overrides: Partial<Settings>): void {
479
- this.settings = normalizeSettings(deepMergeSettings(this.settings, overrides));
487
+ this.overrides = deepMergeSettings(this.overrides, overrides);
488
+ this.rebuildSettings();
480
489
  }
481
490
 
482
491
  private save(): void {
@@ -491,9 +500,9 @@ export class SettingsManager {
491
500
  // Save only global settings (project settings are read-only)
492
501
  writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
493
502
 
494
- // Re-merge project settings into active settings
503
+ // Re-merge project settings into active settings (preserve overrides)
495
504
  const projectSettings = this.loadProjectSettings();
496
- this.settings = normalizeSettings(deepMergeSettings(this.globalSettings, projectSettings));
505
+ this.rebuildSettings(projectSettings);
497
506
  } catch (error) {
498
507
  console.error(`Warning: Could not save settings file: ${error}`);
499
508
  }
@@ -523,6 +532,11 @@ export class SettingsManager {
523
532
  this.globalSettings.modelRoles = {};
524
533
  }
525
534
  this.globalSettings.modelRoles[role] = model;
535
+
536
+ if (this.overrides.modelRoles && this.overrides.modelRoles[role] !== undefined) {
537
+ this.overrides.modelRoles[role] = model;
538
+ }
539
+
526
540
  this.save();
527
541
  }
528
542
 
@@ -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
+ }